from __future__ import annotations

import ast
import os
import re
import shlex
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

from .distromate import bundled_distromate_path

try:
    import tomllib
except ModuleNotFoundError:  # pragma: no cover - Python <3.11
    tomllib = None  # type: ignore[assignment]

WINDOWS_INSTALL_URL = "https://api.distromate.net/install.ps1"
UNIX_INSTALL_URL = "https://api.distromate.net/install.sh"

_SIMPLE_YAML_SCALAR_RE = re.compile(r"^[A-Za-z0-9_./${}-]+$")


@dataclass
class _SpecMetadata:
    name: str | None = None
    distpath: str | None = None
    onefile: bool | None = None
    icon: str | None = None
    version_file: str | None = None


@dataclass
class _DistromateConfig:
    app_name: str
    package_name: str
    package_executable: str
    package_target: str
    package_icon: str | None = None
    package_description: str | None = None
    package_publisher: str | None = None


def _split_dm_args(
    argv: Iterable[str],
) -> tuple[list[str], bool, str | None]:
    py_args: list[str] = []
    publish = False
    dm_version: str | None = None

    args = list(argv)
    i = 0
    while i < len(args):
        arg = args[i]

        if arg == "--":
            py_args.extend(args[i + 1 :])
            break

        if arg in {"--publish", "--dm-publish"}:
            publish = True
            i += 1
            continue

        if arg == "-p":
            next_arg = args[i + 1] if i + 1 < len(args) else None
            if next_arg and not next_arg.startswith("-"):
                py_args.extend([arg, next_arg])
                i += 2
            else:
                publish = True
                i += 1
            continue

        if arg.startswith("--dm-version="):
            dm_version = arg.split("=", 1)[1]
            i += 1
            continue

        if arg == "--dm-version":
            if i + 1 >= len(args):
                raise SystemExit("Missing value for --dm-version.")
            dm_version = args[i + 1]
            i += 2
            continue

        py_args.append(arg)
        i += 1

    return py_args, publish, dm_version


def _read_pyproject_version(start_dir: Path) -> str | None:
    if tomllib is None:
        return None

    for current in [start_dir, *start_dir.parents]:
        pyproject = current / "pyproject.toml"
        if not pyproject.is_file():
            continue
        try:
            data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
        except Exception:
            return None
        return data.get("project", {}).get("version")

    return None


def _yaml_scalar(value: str) -> str:
    value = value.strip()
    if not value:
        return '""'
    if _SIMPLE_YAML_SCALAR_RE.fullmatch(value):
        return value
    escaped = value.replace("\\", "\\\\").replace('"', '\\"')
    return f'"{escaped}"'


def _normalize_yaml_path(path_value: str, start_dir: Path) -> str:
    path = Path(path_value)
    if path.is_absolute():
        try:
            return path.relative_to(start_dir).as_posix()
        except ValueError:
            return path.as_posix()
    return path.as_posix()


def _normalize_icon_value(icon_value: str | None, start_dir: Path) -> str | None:
    if icon_value is None:
        return None

    normalized = icon_value.strip()
    if not normalized:
        return None
    if normalized.lower() == "none":
        return None

    return _normalize_yaml_path(normalized, start_dir)


def _join_yaml_path(*parts: str) -> str:
    normalized: list[str] = []
    for idx, part in enumerate(parts):
        current = part.replace("\\", "/")
        if idx == 0:
            normalized.append(current.rstrip("/"))
        else:
            normalized.append(current.strip("/"))

    merged = "/".join(item for item in normalized if item)
    return merged or "."


def _call_name(node: ast.AST) -> str | None:
    if isinstance(node, ast.Name):
        return node.id
    if isinstance(node, ast.Attribute):
        return node.attr
    return None


def _string_from_expr(node: ast.AST | None, values: dict[str, str]) -> str | None:
    if node is None:
        return None
    if isinstance(node, ast.Constant) and isinstance(node.value, str):
        return node.value
    if isinstance(node, ast.Name):
        return values.get(node.id)
    return None


def _icon_from_expr(node: ast.AST | None, values: dict[str, str]) -> str | None:
    if node is None:
        return None

    direct = _string_from_expr(node, values)
    if direct is not None:
        return direct

    if isinstance(node, (ast.List, ast.Tuple)):
        for item in node.elts:
            icon = _icon_from_expr(item, values)
            if icon:
                return icon

    return None


def _resolve_input_file_path(
    path_value: str,
    start_dir: Path,
    preferred_dir: Path | None = None,
) -> Path:
    path = Path(path_value)
    if path.is_absolute():
        return path

    if preferred_dir is not None:
        preferred_candidate = preferred_dir / path
        if preferred_candidate.is_file():
            return preferred_candidate
    else:
        preferred_candidate = None

    start_candidate = start_dir / path
    if start_candidate.is_file():
        return start_candidate

    if preferred_candidate is not None:
        return preferred_candidate
    return start_candidate


def _read_version_file_metadata(version_path: Path) -> tuple[str | None, str | None]:
    try:
        text = version_path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        try:
            text = version_path.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            return None, None
    except OSError:
        return None, None

    try:
        tree = ast.parse(text)
    except SyntaxError:
        return None, None

    string_values: dict[str, str] = {}
    for node in tree.body:
        if isinstance(node, ast.Assign):
            value = _string_from_expr(node.value, string_values)
            if value is None:
                continue
            for target in node.targets:
                if isinstance(target, ast.Name):
                    string_values[target.id] = value
        elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
            value = _string_from_expr(node.value, string_values)
            if value is not None:
                string_values[node.target.id] = value

    description: str | None = None
    publisher: str | None = None

    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        if _call_name(node.func) != "StringStruct":
            continue

        key_node: ast.AST | None = None
        value_node: ast.AST | None = None
        if len(node.args) >= 2:
            key_node = node.args[0]
            value_node = node.args[1]
        for keyword in node.keywords:
            if keyword.arg in {"name", "key"} and key_node is None:
                key_node = keyword.value
            elif keyword.arg in {"value", "text"} and value_node is None:
                value_node = keyword.value

        key = _string_from_expr(key_node, string_values)
        value = _string_from_expr(value_node, string_values)
        if not key or value is None:
            continue

        value = value.strip()
        if not value:
            continue

        if key == "FileDescription" and description is None:
            description = value
        elif key == "CompanyName" and publisher is None:
            publisher = value

        if description and publisher:
            break

    return description, publisher


def _read_spec_metadata(spec_path: Path) -> _SpecMetadata:
    meta = _SpecMetadata()

    try:
        text = spec_path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        try:
            text = spec_path.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            return meta
    except OSError:
        return meta

    try:
        tree = ast.parse(text)
    except SyntaxError:
        return meta

    string_values: dict[str, str] = {}
    for node in tree.body:
        if isinstance(node, ast.Assign):
            value = _string_from_expr(node.value, string_values)
            if value is None:
                continue
            for target in node.targets:
                if isinstance(target, ast.Name):
                    string_values[target.id] = value
        elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
            value = _string_from_expr(node.value, string_values)
            if value is not None:
                string_values[node.target.id] = value

    exe_name: str | None = None
    collect_name: str | None = None
    exe_distpath: str | None = None
    collect_distpath: str | None = None
    exe_icon: str | None = None
    exe_version_file: str | None = None
    has_exe = False
    has_collect = False

    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        func_name = _call_name(node.func)
        if func_name not in {"EXE", "COLLECT"}:
            continue

        if func_name == "EXE":
            has_exe = True
        if func_name == "COLLECT":
            has_collect = True

        for keyword in node.keywords:
            if keyword.arg == "name":
                value = _string_from_expr(keyword.value, string_values)
                if value is None:
                    continue
                if func_name == "EXE":
                    exe_name = value
                else:
                    collect_name = value
            elif keyword.arg == "distpath":
                value = _string_from_expr(keyword.value, string_values)
                if value is None:
                    continue
                if func_name == "EXE":
                    exe_distpath = value
                else:
                    collect_distpath = value
            elif keyword.arg == "icon" and func_name == "EXE":
                icon_value = _icon_from_expr(keyword.value, string_values)
                if icon_value:
                    exe_icon = icon_value
            elif keyword.arg == "version" and func_name == "EXE":
                version_value = _string_from_expr(keyword.value, string_values)
                if version_value:
                    exe_version_file = version_value

    meta.name = exe_name or collect_name
    meta.distpath = collect_distpath or exe_distpath
    meta.icon = exe_icon
    meta.version_file = exe_version_file
    if has_exe:
        meta.onefile = not has_collect
    return meta


def _parse_pyinstaller_metadata(
    py_args: list[str],
) -> tuple[
    str | None,
    str | None,
    bool | None,
    str | None,
    str | None,
    str | None,
    str | None,
]:
    name: str | None = None
    distpath: str | None = None
    onefile: bool | None = None
    spec_arg: str | None = None
    script_arg: str | None = None
    icon: str | None = None
    version_file: str | None = None

    i = 0
    while i < len(py_args):
        arg = py_args[i]

        if arg == "--":
            for item in py_args[i + 1 :]:
                if item.startswith("-"):
                    continue
                lowered = item.lower()
                if spec_arg is None and lowered.endswith(".spec"):
                    spec_arg = item
                elif script_arg is None and lowered.endswith(".py"):
                    script_arg = item
            break

        if arg in {"-n", "--name"}:
            if i + 1 < len(py_args):
                name = py_args[i + 1]
                i += 2
                continue

        if arg.startswith("--name="):
            name = arg.split("=", 1)[1]
            i += 1
            continue

        if arg == "--distpath":
            if i + 1 < len(py_args):
                distpath = py_args[i + 1]
                i += 2
                continue

        if arg.startswith("--distpath="):
            distpath = arg.split("=", 1)[1]
            i += 1
            continue

        if arg in {"-F", "--onefile"}:
            onefile = True
            i += 1
            continue

        if arg in {"-D", "--onedir"}:
            onefile = False
            i += 1
            continue

        if arg in {"-i", "--icon"}:
            if i + 1 < len(py_args):
                icon = py_args[i + 1]
                i += 2
                continue

        if arg.startswith("--icon="):
            icon = arg.split("=", 1)[1]
            i += 1
            continue

        if arg.startswith("-i") and len(arg) > 2:
            icon = arg[2:]
            i += 1
            continue

        if arg == "--version-file":
            if i + 1 < len(py_args):
                version_file = py_args[i + 1]
                i += 2
                continue

        if arg.startswith("--version-file="):
            version_file = arg.split("=", 1)[1]
            i += 1
            continue

        if not arg.startswith("-"):
            lowered = arg.lower()
            if spec_arg is None and lowered.endswith(".spec"):
                spec_arg = arg
            elif script_arg is None and lowered.endswith(".py"):
                script_arg = arg

        i += 1

    return name, distpath, onefile, spec_arg, script_arg, icon, version_file


def _derive_distromate_config(py_args: list[str], start_dir: Path) -> _DistromateConfig:
    (
        cli_name,
        cli_distpath,
        cli_onefile,
        spec_arg,
        script_arg,
        cli_icon,
        cli_version_file,
    ) = _parse_pyinstaller_metadata(py_args)

    spec_meta = _SpecMetadata()
    spec_path: Path | None = None
    if spec_arg:
        spec_path = Path(spec_arg)
        if not spec_path.is_absolute():
            spec_path = start_dir / spec_path
        spec_meta = _read_spec_metadata(spec_path)

    raw_name = cli_name or spec_meta.name
    if not raw_name and script_arg:
        raw_name = Path(script_arg).stem
    if not raw_name and spec_arg:
        raw_name = Path(spec_arg).stem
    if not raw_name:
        raw_name = start_dir.name or "app"

    package_name = Path(raw_name).name.strip() or "app"
    if package_name.lower().endswith(".exe") and len(package_name) > 4:
        package_name = package_name[:-4]

    raw_target = cli_distpath or spec_meta.distpath or "dist"
    package_target = _normalize_yaml_path(raw_target, start_dir)
    package_icon = _normalize_icon_value(cli_icon or spec_meta.icon, start_dir)

    version_file = cli_version_file or spec_meta.version_file
    version_search_dir = spec_path.parent if spec_path is not None else start_dir
    package_description: str | None = None
    package_publisher: str | None = None
    if version_file:
        version_path = _resolve_input_file_path(
            version_file,
            start_dir=start_dir,
            preferred_dir=version_search_dir,
        )
        package_description, package_publisher = _read_version_file_metadata(version_path)

    onefile = cli_onefile
    if onefile is None:
        onefile = spec_meta.onefile
    if onefile is None:
        onefile = False

    executable_name = package_name
    if os.name == "nt" and not executable_name.lower().endswith(".exe"):
        executable_name += ".exe"

    if onefile:
        package_executable = _join_yaml_path(package_target, executable_name)
    else:
        package_executable = _join_yaml_path(
            package_target,
            package_name,
            executable_name,
        )

    return _DistromateConfig(
        app_name=package_name,
        package_name=package_name,
        package_executable=package_executable,
        package_target=package_target,
        package_icon=package_icon,
        package_description=package_description,
        package_publisher=package_publisher,
    )


def _render_default_distromate_yaml(config: _DistromateConfig) -> str:
    lines = [
        "appid: your-app-id",
        f"name: {_yaml_scalar(config.app_name)}",
        "",
        "update:",
        "  mode: auto",
        "",
        "packer_version: 1.0.0",
        "",
        "package:",
        f"  name: {_yaml_scalar(config.package_name)}",
        f"  description: {_yaml_scalar(config.package_description or 'your-app-description')}",
        f"  executable: {_yaml_scalar(config.package_executable)}",
        f"  target: {_yaml_scalar(config.package_target)}",
        "  args:",
        "    - --config",
        "    - config.json",
        "",
        "  ignores:",
        "    - \".git/\"",
        "    - \"*.map\"",
        "",
        "  files:",
        "    - \"**/*\"",
        "    - \"!**/*.pdb\"",
        "",
        f"  icon: {_yaml_scalar(config.package_icon or 'dist/logo.ico')}",
        f"  publisher: {_yaml_scalar(config.package_publisher or 'your-publisher')}",
        "  language: chinese",
        "  preservePaths:",
        "    - dist",
        "",
        '  artifactName: "${productName}-Setup-${version}-${os}-${arch}.${ext}"',
        '  updateArtifactName: "${productName}-Update-${version}-${os}-${arch}.bin"',
        '  manifestArtifactName: "${productName}-Update-${version}-${os}-${arch}.manifest.json"',
        "",
        "  windows:",
        '    appId: "com.example.app"',
        "    installScope: perMachine",
        "    oneClick: false",
        "    allowChangeInstallationDirectory: true",
        "    runAfterFinish: true",
        "    createStartMenuShortcut: true",
        "",
        "dev:",
        "  projectRoot: .",
        "  executable: npm",
        "  args:",
        "    - run",
        "    - dev",
    ]
    return "\n".join(lines) + "\n"


def _line_indent(line: str) -> int:
    return len(line) - len(line.lstrip(" "))


def _is_comment_or_blank(line: str) -> bool:
    stripped = line.strip()
    return not stripped or stripped.startswith("#")


def _is_key_line(line: str, key: str, indent: int) -> bool:
    if _line_indent(line) != indent or _is_comment_or_blank(line):
        return False
    code = line.split("#", 1)[0].strip()
    match = re.match(r"^([A-Za-z0-9_-]+)\s*:", code)
    return bool(match and match.group(1) == key)


def _find_block_end(lines: list[str], start: int, base_indent: int) -> int:
    for idx in range(start + 1, len(lines)):
        line = lines[idx]
        if _is_comment_or_blank(line):
            continue
        if _line_indent(line) <= base_indent:
            return idx
    return len(lines)


def _infer_child_indent(lines: list[str], start: int, end: int) -> int:
    for idx in range(start + 1, end):
        line = lines[idx]
        if _is_comment_or_blank(line):
            continue
        indent = _line_indent(line)
        if indent > 0:
            return indent
    return 2


def _ensure_root_key_if_missing(lines: list[str], key: str, value: str) -> None:
    for idx, line in enumerate(lines):
        if not _is_key_line(line, key, 0):
            continue
        code = line.split("#", 1)[0]
        key_only = re.match(r"^[A-Za-z0-9_-]+\s*:\s*$", code.strip())
        if key_only:
            lines[idx] = f"{key}: {value}"
        return

    insert_at = 0
    for idx, line in enumerate(lines):
        if _is_key_line(line, "appid", 0):
            insert_at = idx + 1
            break

    lines.insert(insert_at, f"{key}: {value}")


def _update_distromate_yaml(content: str, config: _DistromateConfig) -> str:
    lines = content.splitlines()
    if not lines:
        return _render_default_distromate_yaml(config)

    _ensure_root_key_if_missing(lines, "name", _yaml_scalar(config.app_name))

    package_start: int | None = None
    for idx, line in enumerate(lines):
        if _is_key_line(line, "package", 0):
            package_start = idx
            break

    if package_start is None:
        if lines and lines[-1].strip():
            lines.append("")
        lines.append("package:")
        package_start = len(lines) - 1

    package_end = _find_block_end(lines, package_start, 0)
    child_indent = _infer_child_indent(lines, package_start, package_end)

    updates: list[tuple[str, str]] = [
        ("name", _yaml_scalar(config.package_name)),
        ("executable", _yaml_scalar(config.package_executable)),
        ("target", _yaml_scalar(config.package_target)),
    ]
    if config.package_description:
        updates.append(("description", _yaml_scalar(config.package_description)))
    if config.package_icon:
        updates.append(("icon", _yaml_scalar(config.package_icon)))
    if config.package_publisher:
        updates.append(("publisher", _yaml_scalar(config.package_publisher)))

    for key, value in updates:
        replaced = False
        for idx in range(package_start + 1, package_end):
            if _is_key_line(lines[idx], key, child_indent):
                lines[idx] = " " * child_indent + f"{key}: {value}"
                replaced = True
                break

        if replaced:
            continue

        insert_at = package_end
        while insert_at > package_start + 1 and not lines[insert_at - 1].strip():
            insert_at -= 1
        lines.insert(insert_at, " " * child_indent + f"{key}: {value}")
        package_end += 1

    return "\n".join(lines) + "\n"


def _resolve_distromate_config_path(start_dir: Path) -> Path:
    yaml_path = start_dir / "distromate.yaml"
    yml_path = start_dir / "distromate.yml"

    if yaml_path.is_file():
        return yaml_path
    if yml_path.is_file():
        return yml_path
    return yaml_path


def _ensure_distromate_config(start_dir: Path, py_args: list[str]) -> bool:
    config_path = _resolve_distromate_config_path(start_dir)
    build_config = _derive_distromate_config(py_args, start_dir)

    if config_path.is_file():
        try:
            content = config_path.read_text(encoding="utf-8")
        except UnicodeDecodeError:
            print(
                f"[pyinstaller-plus] {config_path.name} is not UTF-8 encoded, "
                "cannot auto-update."
            )
            return False
        except OSError as exc:
            print(f"[pyinstaller-plus] Failed to read {config_path.name}: {exc}")
            return False

        updated = _update_distromate_yaml(content, build_config)
        if updated != content:
            try:
                config_path.write_text(updated, encoding="utf-8")
            except OSError as exc:
                print(f"[pyinstaller-plus] Failed to update {config_path.name}: {exc}")
                return False
            print(
                f"[pyinstaller-plus] Updated {config_path.name} from "
                "PyInstaller args/spec."
            )
        return True

    try:
        config_path.write_text(
            _render_default_distromate_yaml(build_config),
            encoding="utf-8",
        )
    except OSError as exc:
        print(f"[pyinstaller-plus] Failed to create {config_path.name}: {exc}")
        return False

    print(f"[pyinstaller-plus] Created default {config_path.name} at {config_path}")
    return True


def _run_command(cmd: list[str]) -> int:
    print(f"[pyinstaller-plus] $ {' '.join(cmd)}")
    try:
        completed = subprocess.run(cmd, check=False)
    except FileNotFoundError:
        print(f"[pyinstaller-plus] Command not found: {cmd[0]}")
        return 127
    return completed.returncode


def _default_install_url() -> str:
    if os.name == "nt":
        return WINDOWS_INSTALL_URL
    return UNIX_INSTALL_URL


def _install_hint(url: str) -> str:
    if os.name == "nt":
        return f"powershell -Command \"irm {url} | iex\""
    return f"curl -fsSL {shlex.quote(url)} | sh"


def _install_distromate(url: str) -> bool:
    if os.name == "nt":
        cmd = [
            "powershell",
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-Command",
            f"irm {url} | iex",
        ]
        return _run_command(cmd) == 0

    shell = shutil.which("sh")
    if not shell:
        print("[pyinstaller-plus] shell not found (missing 'sh').")
        return False

    if shutil.which("curl"):
        cmd = [shell, "-c", f"curl -fsSL {shlex.quote(url)} | sh"]
        return _run_command(cmd) == 0

    if shutil.which("wget"):
        cmd = [shell, "-c", f"wget -qO- {shlex.quote(url)} | sh"]
        return _run_command(cmd) == 0

    print("[pyinstaller-plus] curl/wget not found, cannot run install script.")
    return False


def _find_distromate() -> str | None:
    path = shutil.which("distromate")
    if path:
        return path
    return bundled_distromate_path()


def _is_distromate_available(cmd: str) -> bool:
    try:
        completed = subprocess.run(
            [cmd, "--version"],
            check=False,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
    except OSError:
        return False
    return completed.returncode == 0


def _ensure_distromate(install_url: str | None = None) -> str | None:
    install_url = install_url or _default_install_url()
    found = _find_distromate()
    if found and _is_distromate_available(found):
        return found
    if found:
        print("[pyinstaller-plus] found distromate command, but it is not available.")

    print("[pyinstaller-plus] distromate CLI not found. Installing...")
    if not _install_distromate(install_url):
        print(f"[pyinstaller-plus] Install it with: {_install_hint(install_url)}")
        return None

    found = _find_distromate()
    if not found or not _is_distromate_available(found):
        print("[pyinstaller-plus] distromate install finished, but CLI is unavailable.")
        return None
    return found


def main(argv: list[str] | None = None) -> int:
    args = sys.argv[1:] if argv is None else argv
    py_args, publish, dm_version = _split_dm_args(args)

    if not py_args:
        py_args = ["-h"]

    pyinstaller_cmd = [sys.executable, "-m", "PyInstaller", *py_args]
    rc = _run_command(pyinstaller_cmd)
    if rc != 0:
        return rc
    if len(py_args) == 1 and py_args[0] in {"-h", "--help", "--version"}:
        return 0

    distromate_cmd = _ensure_distromate()
    if not distromate_cmd:
        return 127

    if dm_version is None:
        dm_version = _read_pyproject_version(Path(os.getcwd()))
    if not dm_version:
        print(
            "[pyinstaller-plus] distromate version not specified. "
            "Use --dm-version <version> to continue."
        )
        return 2

    if not _ensure_distromate_config(Path(os.getcwd()), py_args):
        return 1

    dm_cmd = [distromate_cmd, "publish" if publish else "package", "-v", dm_version]
    return _run_command(dm_cmd)


if __name__ == "__main__":
    raise SystemExit(main())
