"""
MKLink Serial Bridge — VSCode/CMake/YCT 工程解析。

零外部依赖（仅 stdlib json/re/pathlib），从 .code-workspace、
.vscode/settings.json、project.yct、CMakeLists.txt 和 build/ 产物中提取
设备、输出文件、内存布局、编译宏和 include 路径。
"""

from __future__ import annotations

import json
import os
import re
from pathlib import Path


def find_vscode_project(project_root: str) -> str | None:
    """在项目目录中查找 VSCode/CMake 工程根目录。

    支持的输入形式：
    - 项目根目录（含 CMakeLists.txt / project.yct / .vscode/settings.json）
    - 直接传入 .code-workspace 或 CMakeLists.txt
    - 一级子目录中存在 VSCode/CMake 工程
    """
    root = Path(project_root)

    if root.is_file():
        if root.suffix == ".code-workspace" or root.name == "CMakeLists.txt":
            return str(root.parent.resolve())
        if root.name == "project.yct":
            return str(root.parent.resolve())
        return None

    candidates = [root]
    if root.is_dir():
        candidates.extend(p for p in sorted(root.iterdir()) if p.is_dir())

    for candidate in candidates:
        if _looks_like_vscode_project(candidate):
            return str(candidate.resolve())
    return None


def parse_vscode_project(project_root: str) -> dict | None:
    """解析 VSCode/CMake/YCT 工程，返回 project_info 兼容字典。"""
    root = Path(project_root).resolve()
    if not root.is_dir():
        return None

    cmakelists = root / "CMakeLists.txt"
    yct_path = root / "project.yct"
    settings_path = root / ".vscode" / "settings.json"
    workspace_path = next(iter(sorted(root.glob("*.code-workspace"))), None)

    if not cmakelists.exists() and not yct_path.exists() and not settings_path.exists():
        return None

    yct = _read_json(yct_path) if yct_path.exists() else {}
    settings = _read_json(settings_path) if settings_path.exists() else {}
    cmake_text = _read_text(cmakelists)

    yct_project = yct.get("project", {}) if isinstance(yct, dict) else {}
    configure_args = settings.get("cmake.configureArgs", []) if isinstance(settings, dict) else []
    cmake_defs = _parse_cmake_defines(configure_args)

    project_name = (
        str(yct_project.get("name") or "").strip()
        or _parse_cmake_project_name(cmake_text)
        or root.name
    )
    target_elf = _parse_project_elf(cmake_text) or f"{project_name}.elf"
    target_stem = Path(target_elf).stem

    build_dir = _resolve_path(
        str(settings.get("cmake.buildDirectory", "") if isinstance(settings, dict) else ""),
        root,
    ) or (root / "build")
    source_dir = _resolve_path(
        str(settings.get("cmake.sourceDirectory", "") if isinstance(settings, dict) else ""),
        root,
    ) or root

    compile_commands = build_dir / "compile_commands.json"
    cc_info = _parse_compile_commands(compile_commands)
    linker_script = _find_linker_script(root, cmake_text)
    memory = _parse_linker_memory(linker_script) if linker_script else {}

    device = (
        str(yct_project.get("ideDeviceName") or "").strip()
        or str(yct_project.get("family") or "").strip()
        or cmake_defs.get("DEVICE_NAME", "")
        or _device_from_defines(cc_info.get("defines", []))
    )
    family = str(yct_project.get("family") or cmake_defs.get("DEVICE_NAME", "")).strip()
    toolchain = str(yct_project.get("toolchain") or cmake_defs.get("TOOLCHAIN", "")).strip()
    compiler = _compiler_from_toolchain(toolchain, cc_info.get("compiler", ""))

    hex_path = _find_artifact(build_dir, target_stem, ".hex")
    map_path = _find_artifact(build_dir, target_stem, ".map")
    elf_path = _find_artifact(build_dir, target_stem, ".elf") or _find_artifact(build_dir, target_stem, ".axf")

    result = {
        "vscode_root": str(root),
        "workspace_path": str(workspace_path.resolve()) if workspace_path else "",
        "cmakelists_path": str(cmakelists.resolve()) if cmakelists.exists() else "",
        "settings_path": str(settings_path.resolve()) if settings_path.exists() else "",
        "yct_path": str(yct_path.resolve()) if yct_path.exists() else "",
        "project_name": project_name,
        "target_name": target_elf,
        "device": device,
        "family": family,
        "part_number": str(yct_project.get("partNumber") or "").strip(),
        "vendor": "YTMicro" if device.upper().startswith("YTM") else "",
        "toolchain": toolchain,
        "compiler": compiler,
        "build_dir": str(build_dir),
        "source_dir": str(source_dir),
        "hex_path": str(hex_path) if hex_path else str(build_dir / f"{target_stem}.hex"),
        "map_path": str(map_path) if map_path else str(build_dir / f"{target_stem}.map"),
        "elf_path": str(elf_path) if elf_path else str(build_dir / f"{target_stem}.elf"),
        "axf_path": str(elf_path) if elf_path else str(build_dir / f"{target_stem}.elf"),
        "linker_script": str(linker_script) if linker_script else "",
        "flash_base": memory.get("flash_base", ""),
        "flash_size": memory.get("flash_size", 0),
        "ram_base": memory.get("ram_base", ""),
        "ram_size": memory.get("ram_size", 0),
        "include_paths": cc_info.get("include_paths", []),
        "defines": cc_info.get("defines", []),
    }

    _fill_flm_from_profiles(result)
    result["flash_base"] = result.get("flash_base") or "0x00000000"
    result["ram_base"] = result.get("ram_base") or "0x20000000"
    return result


def _looks_like_vscode_project(path: Path) -> bool:
    if not path.is_dir():
        return False
    has_vscode = (path / ".vscode" / "settings.json").exists() or any(path.glob("*.code-workspace"))
    has_cmake = (path / "CMakeLists.txt").exists()
    has_yct = (path / "project.yct").exists()
    return has_yct or (has_vscode and has_cmake)


def _read_text(path: Path) -> str:
    if not path.exists():
        return ""
    try:
        return path.read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return ""


def _read_json(path: Path) -> dict:
    text = _read_text(path)
    if not text:
        return {}
    try:
        return json.loads(_strip_json_comments(text))
    except json.JSONDecodeError:
        return {}


def _strip_json_comments(text: str) -> str:
    lines = []
    for line in text.splitlines():
        stripped = line.lstrip()
        if stripped.startswith("//"):
            continue
        lines.append(line)
    return "\n".join(lines)


def _parse_cmake_defines(args: list) -> dict[str, str]:
    result = {}
    for arg in args:
        if not isinstance(arg, str) or not arg.startswith("-D"):
            continue
        body = arg[2:]
        if "=" in body:
            key, value = body.split("=", 1)
            result[key] = value
    return result


def _parse_cmake_project_name(text: str) -> str:
    m = re.search(r"\bproject\s*\(\s*([^\s\)]+)", text, re.IGNORECASE)
    return m.group(1).strip() if m else ""


def _parse_project_elf(text: str) -> str:
    m = re.search(r"\bset\s*\(\s*project_elf\s+([^\s\)]+)", text, re.IGNORECASE)
    return m.group(1).strip() if m else ""


def _resolve_path(value: str, root: Path) -> Path | None:
    if not value:
        return None
    value = value.strip().strip('"')
    replacements = {
        "${workspaceFolder}": str(root),
        "${workspaceRoot}": str(root),
        "${CMAKE_SOURCE_DIR}": str(root),
        "${PROJ_DIR}": str(root),
        "${CMAKE_CURRENT_BINARY_DIR}": str(root / "build"),
    }
    for key, replacement in replacements.items():
        value = value.replace(key, replacement)
    p = Path(value)
    if not p.is_absolute():
        p = root / p
    return p.resolve()


def _parse_compile_commands(path: Path) -> dict:
    if not path.exists():
        return {"include_paths": [], "defines": [], "compiler": ""}
    try:
        data = json.loads(path.read_text(encoding="utf-8", errors="ignore"))
    except (json.JSONDecodeError, OSError):
        return {"include_paths": [], "defines": [], "compiler": ""}
    if not isinstance(data, list) or not data:
        return {"include_paths": [], "defines": [], "compiler": ""}
    command = str(data[0].get("command", ""))
    compiler = command.split()[0] if command.split() else ""
    defines = _unique(re.findall(r"(?:^|\s)-D([^\s]+)", command))
    includes = _unique(p.strip('"') for p in re.findall(r"(?:^|\s)-I(\"[^\"]+\"|\S+)", command))
    return {"include_paths": includes, "defines": defines, "compiler": compiler}


def _find_linker_script(root: Path, cmake_text: str) -> Path | None:
    m = re.search(r"-T\s*([^\"'\s;\)]+)", cmake_text)
    if not m:
        m = re.search(r"-T\$\{[^}]+\}/([^\"'\s;\)]+)", cmake_text)
    if m:
        candidate = _resolve_path(m.group(1), root)
        if candidate and candidate.exists():
            return candidate
    for pattern in ("board/*.ld", "*.ld", "**/*.ld"):
        matches = sorted(root.glob(pattern))
        if matches:
            return matches[0].resolve()
    return None


def _parse_linker_memory(path: Path) -> dict:
    text = _read_text(path)
    regions = []
    for m in re.finditer(
        r"^\s*(\w+)\s*\(([^)]*)\)\s*:\s*ORIGIN\s*=\s*([^,]+),\s*LENGTH\s*=\s*([^\s/]+)",
        text,
        re.IGNORECASE | re.MULTILINE,
    ):
        name = m.group(1).upper()
        attrs = m.group(2).upper()
        origin = _parse_int(m.group(3))
        length = _parse_int(m.group(4))
        regions.append((name, attrs, origin, length))

    flash = [(o, l) for name, attrs, o, l in regions if "R" in attrs and "X" in attrs and "RAM" not in name]
    ram = [(o, l) for name, attrs, o, l in regions if "W" in attrs and ("RAM" in name or "STACK" in name)]
    result = {}
    if flash:
        base = min(o for o, _ in flash)
        end = max(o + l for o, l in flash)
        result["flash_base"] = f"0x{base:08X}"
        result["flash_size"] = end - base
    if ram:
        base = min(o for o, _ in ram)
        end = max(o + l for o, l in ram)
        result["ram_base"] = f"0x{base:08X}"
        result["ram_size"] = end - base
    return result


def _parse_int(value: str) -> int:
    s = value.strip().rstrip(",")
    mult = 1
    if s[-1:].lower() == "k":
        mult = 1024
        s = s[:-1]
    elif s[-1:].lower() == "m":
        mult = 1024 * 1024
        s = s[:-1]
    return int(s, 0) * mult


def _find_artifact(build_dir: Path, stem: str, suffix: str) -> Path | None:
    candidate = build_dir / f"{stem}{suffix}"
    if candidate.exists():
        return candidate.resolve()
    matches = sorted(build_dir.glob(f"*{suffix}")) if build_dir.exists() else []
    return matches[0].resolve() if matches else candidate.resolve()


def _device_from_defines(defines: list[str]) -> str:
    for define in defines:
        name = define.split("=", 1)[0]
        if name.startswith("CPU_"):
            return name[4:]
    for define in defines:
        name = define.split("=", 1)[0]
        if name.startswith("YTM32"):
            return name
    return ""


def _compiler_from_toolchain(toolchain: str, compiler_path: str) -> str:
    t = toolchain.upper()
    c = os.path.basename(compiler_path).lower()
    if "GCC" in t or "gcc" in c:
        return "gcc"
    if "IAR" in t or "EWARM" in t:
        return "iar"
    if "KEIL" in t or "ARMCLANG" in c:
        return "ac6"
    return toolchain.lower() if toolchain else "cmake"


def _fill_flm_from_profiles(result: dict) -> None:
    try:
        from mklink.profiles import load_mcu_profiles, match_mcu_by_device
    except Exception:
        return
    profiles = load_mcu_profiles()
    key = match_mcu_by_device(result.get("device", ""), profiles)
    if not key or key == "custom":
        return
    profile = profiles.get(key, {})
    flm_path = profile.get("flm_path", "")
    if flm_path:
        result["flm_name"] = flm_path
    if not result.get("flash_base"):
        result["flash_base"] = profile.get("flash_base", "")
    if not result.get("ram_base"):
        result["ram_base"] = profile.get("ram_base", "")
    if not result.get("flash_size") or not result.get("ram_size"):
        for region in profile.get("regions", []):
            if region.get("name") == "flash" and not result.get("flash_size"):
                result["flash_size"] = _parse_int(str(region.get("size", "0")))
            if region.get("name") == "ram" and not result.get("ram_size"):
                result["ram_size"] = _parse_int(str(region.get("size", "0")))


def _unique(values) -> list[str]:
    result = []
    seen = set()
    for value in values:
        if not value or value in seen:
            continue
        seen.add(value)
        result.append(value)
    return result
