#!/usr/bin/env python3
"""Read-only, standard-library onboarding checks for U R Chinese?!.

No install, login, fetch, editor launch, repository init, report file, or global
configuration access. JSON goes to stdout. Exit 0=all checked/pass, 1=action or
verification needed, 2=invalid invocation/configuration. Python 3.8+.
"""
import argparse
import datetime
import json
import math
import os
import pathlib
import platform
import plistlib
import re
import shutil
import subprocess
import sys
import urllib.parse

EXPECTED_VERSION = "6000.6.6f1"
EXPECTED_REVISION = "d20c9ac777da"
DEFAULT_LARGE_MIB = 100
SCAN_LIMIT = 10000
READ_LIMIT = 1024 * 1024
# These locations are generated, tool-managed, or contain this doctor's fixtures.
PRUNE_NAMES = {".git", "Library", "Temp", "Logs", "Builds", "node_modules", ".tests-temp", "__pycache__"}


class ConfigurationError(ValueError):
    pass


def read_small(path):
    with pathlib.Path(path).open("rb") as stream:
        data = stream.read(READ_LIMIT + 1)
    if len(data) > READ_LIMIT:
        raise ValueError("metadata file exceeds read limit")
    return data


def has_url_credentials(value):
    if not value:
        return False
    if "://" not in value:
        match = re.match(r"^(?:[^/@:]+@)?([^/:]+):(.+)$", value)
        return bool(match and not re.match(r"^[A-Za-z]:[\\/]", value) and ("?" in match.group(2) or "#" in match.group(2)))
    try:
        parsed = urllib.parse.urlsplit(value)
        unused_port_validation = parsed.port
        return bool(parsed.query or parsed.fragment or (parsed.scheme.lower() in ("http", "https") and (parsed.username is not None or parsed.password is not None)))
    except ValueError:
        return True


def safe_url(value):
    """Remove URL userinfo/query/fragment, including credential-bearing remotes."""
    if not value:
        return ""
    if "://" in value:
        try:
            parsed = urllib.parse.urlsplit(value)
            host = parsed.hostname or ""
            if parsed.port:
                host += ":" + str(parsed.port)
            return urllib.parse.urlunsplit((parsed.scheme, host, parsed.path, "[redacted]" if parsed.query else "", ""))
        except ValueError:
            return "[invalid remote redacted]"
    match = re.match(r"^(?:[^/@:]+@)?([^/:]+):(.+)$", value)
    if match and not re.match(r"^[A-Za-z]:[\\/]", value):
        path = match.group(2).split("?", 1)[0].split("#", 1)[0]
        return match.group(1) + ":" + path
    return value


def canonical_remote(value, base):
    """Conservative identity: protocol, user (SSH), host, port and repository path."""
    if not value:
        return None
    if "://" in value:
        try:
            parsed = urllib.parse.urlsplit(value)
            scheme = parsed.scheme.lower()
            if scheme == "file":
                return ("local", str(pathlib.Path(urllib.parse.unquote(parsed.path)).resolve()))
            port = parsed.port if parsed.port is not None else {"http": 80, "https": 443, "ssh": 22, "git": 9418}.get(scheme)
            return (scheme, parsed.username if scheme == "ssh" else None, (parsed.hostname or "").lower(), port, re.sub(r"\.git$", "", parsed.path.strip("/")))
        except ValueError:
            return ("invalid-url",)
    match = re.match(r"^(?:([^/@:]+)@)?([^/:]+):(.+)$", value)
    if match and not re.match(r"^[A-Za-z]:[\\/]", value):
        return ("ssh", match.group(1), match.group(2).lower(), 22, re.sub(r"\.git$", "", match.group(3).strip("/")))
    path = pathlib.Path(value).expanduser()
    if not path.is_absolute():
        path = base / path
    return ("local", str(path.resolve()))


def editor_like_path(path):
    parts = [part.lower() for part in pathlib.Path(path).resolve().parts]
    return any(part in ("editor", "unity.app") for part in parts) or any(part.endswith(".app") for part in parts)


def git_environment():
    env = os.environ.copy()
    # Do not inherit a caller's redirection into an unrelated Git worktree/index.
    for name in list(env):
        if name.startswith("GIT_TRACE") or name in {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT"} or name.startswith("GIT_CONFIG_KEY_") or name.startswith("GIT_CONFIG_VALUE_"):
            env.pop(name, None)
    env.update({"GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0", "GIT_LFS_SKIP_SMUDGE": "1", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", "GIT_PAGER": "cat", "LC_ALL": "C"})
    return env


class ReadOnlyGit:
    """Only these exact read commands can reach subprocess; never shell=True."""
    ALLOWED = {
        ("--version",), ("rev-parse", "--show-toplevel"),
        ("rev-parse", "--absolute-git-dir"), ("rev-parse", "--git-common-dir"),
        ("symbolic-ref", "--quiet", "--short", "HEAD"),
        ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"),
        ("rev-list", "--left-right", "--count", "HEAD...@{upstream}"),
        ("remote", "-v"),
        ("for-each-ref", "--format=%(refname)%00%(upstream)%00%(upstream:remotename)%00%(upstream:remoteref)", "refs/heads"),
        ("status", "--porcelain=v1", "-z", "--untracked-files=normal", "--ignore-submodules=all"),
        ("ls-files", "-z"), ("ls-files", "--others", "--exclude-standard", "-z"),
        ("check-attr", "-z", "--stdin", "filter"), ("check-ignore", "-z", "--stdin"), ("lfs", "version"),
    }
    def __init__(self, executable, workspace):
        self.executable = str(executable)
        self.workspace = pathlib.Path(workspace)
        self.calls = []

    def run(self, *arguments, **kwargs):
        if tuple(arguments) not in self.ALLOWED:
            raise ValueError("non-read-only Git command rejected")
        self.calls.append(list(arguments))
        options = ["-c", "core.fsmonitor=false", "-c", "core.untrackedCache=false", "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "credential.interactive=never"]
        command = [self.executable] + options + ["-C", str(self.workspace)] + list(arguments)
        try:
            result = subprocess.run(command, input=kwargs.get("input_bytes"), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=git_environment(), timeout=20, check=False)
            return result.returncode, result.stdout.decode("utf-8", "replace"), "" if result.returncode == 0 else "Git read command failed (exit %s)" % result.returncode
        except (OSError, subprocess.TimeoutExpired) as error:
            return -1, "", type(error).__name__ + " during Git read command"


def load_config(path):
    if path is None:
        return {}
    try:
        data = json.loads(read_small(path).decode("utf-8"))
    except (OSError, UnicodeError, ValueError) as error:
        raise ConfigurationError("Cannot read config JSON: " + type(error).__name__)
    if not isinstance(data, dict):
        raise ConfigurationError("Config must be a JSON object")
    nullable_strings = ["workspace", "repo_url", "base_branch", "expected_upstream", "remote_name", "unity_version", "unity_revision", "project_subdir", "scene_path", "editor_path", "editor_volume"]
    for key in nullable_strings:
        if data.get(key) is not None and not isinstance(data[key], str):
            raise ConfigurationError(key + " must be a string or null")
    expected_upstream = data.get("expected_upstream")
    if expected_upstream is not None and not re.match(r"^refs/remotes/[^/]+/.+$", expected_upstream):
        raise ConfigurationError("expected_upstream须为完整refs/remotes/<remote>/<branch>或null。")
    if data.get("repo_url") is not None and (not data["repo_url"].strip() or has_url_credentials(data["repo_url"])):
        raise ConfigurationError("repo_url含凭据、query、fragment或无效URL；请使用不含凭据的已确认仓库地址（已隐藏输入）。")
    if not isinstance(data.get("tools_paths", {}), dict) or not isinstance(data.get("disk_thresholds", {}), dict):
        raise ConfigurationError("tools_paths/disk_thresholds must be objects")
    for value in data.get("tools_paths", {}).values():
        if value is not None and not isinstance(value, str):
            raise ConfigurationError("tool paths must be strings or null")
    for key in ("workspace_min_free_gib", "editor_min_free_gib"):
        value = data.get("disk_thresholds", {}).get(key)
        if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0):
            raise ConfigurationError(key + " must be finite, nonnegative, or null")
    if not isinstance(data.get("unity_modules", []), list) or any(not isinstance(value, str) for value in data.get("unity_modules", [])):
        raise ConfigurationError("unity_modules must be a string array")
    if data.get("project_subdir", "game") not in (None, "game"):
        raise ConfigurationError("This project uses game/ as its sole Unity root")
    return data


class Doctor:
    def __init__(self, workspace, config=None, editor_path=None, editor_volume=None):
        self.workspace = pathlib.Path(workspace).expanduser().resolve()
        self.config = config or {}
        self.editor_override = editor_path or self.config.get("editor_path")
        self.editor_volume = editor_volume or self.config.get("editor_volume")
        self.checks = []
        self.git = None
        self.tracked = []
        self.untracked = []
        self.exact_repo = False
        self.selected_editor = None

    def add(self, key, status, reason, **details):
        self.checks.append({"id": key, "status": status, "reason": reason, "details": details})

    def tool_candidates(self, name):
        configured = self.config.get("tools_paths", {}).get(name)
        result = [pathlib.Path(configured).expanduser()] if configured else []
        commands = {"git": ["git"], "codex": ["codex"], "unity_hub": ["unityhub", "Unity Hub"], "unity_cli": ["unity"], "unity_editor": []}[name]
        for command in commands:
            found = shutil.which(command)
            if found:
                path = pathlib.Path(found)
                if name != "unity_cli" or not editor_like_path(path):
                    result.append(path)
        home = pathlib.Path.home()
        if name == "unity_cli":
            result += [home / ".unity/bin/unity", home / ".local/bin/unity"]
            local = os.environ.get("LOCALAPPDATA")
            if local:
                result.append(pathlib.Path(local) / "Unity/bin/unity.exe")
        if platform.system() == "Darwin":
            if name == "codex":
                for suffix in ("ChatGPT.app", "Codex.app"):
                    result += [pathlib.Path("/Applications") / suffix, home / "Applications" / suffix]
            if name == "unity_hub":
                result += [pathlib.Path("/Applications/Unity Hub.app"), home / "Applications/Unity Hub.app"]
            if name == "unity_editor":
                version = self.config.get("unity_version") or EXPECTED_VERSION
                result += [pathlib.Path("/Applications/Unity/Hub/Editor") / version / "Unity.app", home / "Applications/Unity/Hub/Editor" / version / "Unity.app"]
        elif platform.system() == "Windows" and name != "unity_cli":
            roots = [os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)"), os.environ.get("LOCALAPPDATA")]
            for root in filter(None, roots):
                root = pathlib.Path(root)
                relative = {"git": ["Git/cmd/git.exe", "Git/bin/git.exe"], "codex": ["Programs/Codex/Codex.exe", "Codex/Codex.exe", "Programs/ChatGPT/ChatGPT.exe"], "unity_hub": ["Unity Hub/Unity Hub.exe", "UnityHub/Unity Hub.exe", "Programs/Unity Hub/Unity Hub.exe"], "unity_editor": ["Unity/Hub/Editor/%s/Editor/Unity.exe" % (self.config.get("unity_version") or EXPECTED_VERSION)]}[name]
                result += [root / item for item in relative]
        if name == "unity_hub":
            result += [self.workspace / "game/Tools/Unity Hub.app", self.workspace / "game/Tools/Unity Hub/Unity Hub.exe"]
        if name == "unity_editor" and self.editor_override:
            result.insert(0, pathlib.Path(self.editor_override).expanduser())
        unique = []
        seen = set()
        for path in result:
            text = str(path)
            if text not in seen:
                seen.add(text)
                unique.append(path)
        return unique

    def check_tools(self):
        for name in ("git", "codex", "unity_hub", "unity_cli"):
            candidates = self.tool_candidates(name)
            found = next((path for path in candidates if path.exists()), None)
            if found is None:
                status = "unknown" if name == "codex" and platform.system() == "Windows" else "warn"
                reason = "未定位路径；Windows Store安装未穷举，请在官方应用UI核验。" if status == "unknown" else "未在PATH、配置或常见安装位置找到；没有安装或启动工具。"
                self.add("tool." + name, status, reason, searched=[str(p) for p in candidates])
                continue
            details = {"path": str(found), "launched": False}
            if name == "git":
                if not found.is_file() or found.name.lower() not in ("git", "git.exe"):
                    self.add("tool.git", "fail", "Git路径须指向git/git.exe文件；未执行该路径。", **details)
                    continue
                self.git = ReadOnlyGit(found, self.workspace)
                code, version, _ = self.git.run("--version")
                details.update(version=version.strip() if code == 0 else None, launched=True)
                self.add("tool.git", "pass" if code == 0 else "fail", "仅执行只读git --version。", **details)
            elif name == "unity_cli":
                valid = found.is_file() and found.name.lower() in ("unity", "unity.exe") and not editor_like_path(found)
                self.add("tool.unity_cli", "pass" if valid else "fail", "独立Unity CLI候选路径；未执行或验证发行身份。" if valid else "所配路径是Editor/应用包或不是CLI文件，不能视为独立Unity CLI。", kind="standalone_cli_candidate", **details)
            else:
                self.add("tool." + name, "pass", "发现现有路径；未启动、登录或验证运行状态。", **details)
        editor_candidates = self.tool_candidates("unity_editor")
        self.check_editor(next((p for p in editor_candidates if p.exists()), None), editor_candidates)

    def check_editor(self, found, candidates):
        if found is None:
            self.add("tool.unity_editor", "warn", "未定位现有Editor；可用--editor-path指向已安装目录，doctor不会安装/启动Unity。", searched=[str(p) for p in candidates], runtime_verified=False)
            return
        # Accept a Unity.app, Unity executable, Editor folder, or version folder.
        candidates = [found, found / "Unity.app", found / "Editor/Unity.exe", found / "Unity.exe"]
        selected = next((p for p in candidates if p.is_file() and p.name.lower() in ("unity", "unity.exe")), None)
        app = next((p for p in [found, found / "Unity.app"] + list(found.parents)[:4] if p.suffix.lower() == ".app" and (p / "Contents/Info.plist").is_file()), None)
        if app:
            selected = app / "Contents/MacOS/Unity"
        if selected is None and found.is_dir():
            selected = next((p for p in [found / "Editor/Unity.exe", found / "Unity.exe"] if p.is_file()), None)
        expected_version = self.config.get("unity_version") or EXPECTED_VERSION
        expected_revision = self.config.get("unity_revision") or EXPECTED_REVISION
        version = revision = None
        evidence = []
        if app:
            try:
                metadata = plistlib.loads(read_small(app / "Contents/Info.plist"))
                for key in ("UnityVersion", "CFBundleShortVersionString", "CFBundleVersion"):
                    match = re.search(r"\d+\.\d+\.\d+[abfp]\d+", str(metadata.get(key, "")))
                    if match:
                        version = match.group(0); evidence.append("Info.plist:" + key); break
                for key in ("UnityRevision", "UnityBuildRevision", "GitRevision", "BuildRevision", "Revision", "UnityBuildNumber", "CFBundleVersion"):
                    match = re.search(r"(?<![0-9a-f])[0-9a-f]{12}(?![0-9a-f])", str(metadata.get(key, "")), re.I)
                    if match:
                        revision = match.group(0).lower(); evidence.append("Info.plist:" + key); break
            except (OSError, ValueError, plistlib.InvalidFileException):
                evidence.append("Info.plist unreadable")
        if version is None:
            match = re.search(r"\d+\.\d+\.\d+[abfp]\d+", str(found))
            if match:
                version = match.group(0); evidence.append("directory-name inference (unverified)")
        self.selected_editor = selected if selected and selected.exists() else found
        status = "fail" if version and version != expected_version or revision and revision != expected_revision else "pass" if version == expected_version and revision == expected_revision and selected and selected.is_file() else "warn"
        self.add("tool.unity_editor", status, "仅检查路径/Info.plist；版本或revision缺失均为未核实，未运行Editor或触碰许可。", path=str(found), executable_present=bool(selected and selected.is_file()), version=version, revision=revision, expected_version=expected_version, expected_revision=expected_revision, evidence=evidence, runtime_verified=False)
        modules = self.config.get("unity_modules", [])
        if modules:
            self.add("unity.modules", "unknown", "只读doctor不启动Hub安装/验证模块；需按目标平台人工核对。", requested=modules)

    def check_project(self):
        project = self.workspace / "game"
        version_file = project / "ProjectSettings/ProjectVersion.txt"
        valid = all((project / name).is_dir() for name in ("Assets", "Packages", "ProjectSettings"))
        self.add("workspace", "pass" if self.workspace.is_dir() else "fail", "使用调用方选择的workspace；没有克隆、创建仓库或改动目录。", path=str(self.workspace))
        self.add("unity.root", "pass" if valid else "fail", "Unity工程根必须为workspace/game，包含Assets/Packages/ProjectSettings。", path=str(project))
        competitors = []
        inspected = 0
        if self.workspace.is_dir():
            queue = [(self.workspace, 0)]
            while queue and inspected < 2000:
                folder, depth = queue.pop()
                if folder != project and all((folder / name).is_dir() for name in ("Assets", "ProjectSettings", "Packages")):
                    competitors.append(str(folder.relative_to(self.workspace)) or ".")
                if depth >= 3:
                    continue
                try:
                    for child in folder.iterdir():
                        if child.name in PRUNE_NAMES or child.name in ("Tools", "local", "PackageCache") or child.is_symlink():
                            continue
                        if child.is_dir():
                            inspected += 1; queue.append((child, depth + 1))
                except OSError:
                    pass
        self.add("unity.unique_root", "fail" if competitors else "pass", "检查三层内的其他Unity工程根；跳过工具、生成目录与符号链接。", competitors=competitors, max_depth=3, scan_truncated=bool(queue) if self.workspace.is_dir() else False)
        try:
            text = read_small(version_file).decode("utf-8-sig")
            version = re.search(r"^m_EditorVersion:\s*(\S+)", text, re.M)
            revision = re.search(r"^m_EditorVersionWithRevision:\s*\S+\s*\(([0-9a-f]+)\)", text, re.M)
            actual = version.group(1) if version else None
            build = revision.group(1) if revision else None
            expected = self.config.get("unity_version") or EXPECTED_VERSION
            expected_build = self.config.get("unity_revision") or EXPECTED_REVISION
            self.add("unity.project_version", "pass" if actual == expected and build == expected_build else "fail", "核对工程声明，不据此推断本机已装相同Editor。", path=str(version_file), version=actual, revision=build, expected_version=expected, expected_revision=expected_build)
        except (OSError, UnicodeError, ValueError):
            self.add("unity.project_version", "fail", "ProjectVersion.txt缺失或不可读。", path=str(version_file))
        self.add("ui.manual_acceptance", "unknown", "未运行GUI/Editor；中文glyph、工位交互与Play Mode仍需人工验收。系统字体候选不保证中文覆盖。")

    def check_disk(self, key, path, threshold):
        if path is None:
            self.add("disk." + key, "unknown", "未选择Editor卷且未定位Editor；可配置editor_volume。")
            return
        probe = pathlib.Path(path).expanduser().resolve()
        while not probe.exists() and probe != probe.parent:
            probe = probe.parent
        try:
            usage = shutil.disk_usage(probe)
            free_gib = usage.free / (1024 ** 3)
            status = "unknown" if threshold is None else "pass" if free_gib >= threshold else "fail"
            reason = "空间需求阈值未给定，仅报告可用量，不能判断够用。" if threshold is None else "按调用方配置的阈值比较；不是Unity完整安装/构建容量保证。"
            self.add("disk." + key, status, reason, requested_path=str(path), probed_path=str(probe), free_bytes=usage.free, free_gib=round(free_gib, 3), total_bytes=usage.total, required_min_free_gib=threshold, device=os.stat(probe).st_dev)
        except OSError as error:
            self.add("disk." + key, "unknown", "无法读取所选卷空间：" + type(error).__name__)

    def check_repo(self):
        if self.git is None or not self.workspace.is_dir():
            self.add("git.exact_root", "fail", "没有可用Git或workspace，无法确认仓库来源。")
            return
        code, output, _ = self.git.run("rev-parse", "--show-toplevel")
        if code:
            self.add("git.exact_root", "fail", "当前workspace不是Git工作树；未自动git init或联网获取来源。")
            return
        root = pathlib.Path(output.strip()).resolve()
        if root != self.workspace:
            self.add("git.exact_root", "fail", "检测到外层仓库；它不是当前workspace的精确root，停止把外层仓库当作本项目。", detected_root=str(root), requested_root=str(self.workspace))
            return
        self.exact_repo = True
        self.add("git.exact_root", "pass", "workspace与Git实际root完全一致。", root=str(root))
        marker_code, git_dir, _ = self.git.run("rev-parse", "--absolute-git-dir")
        markers = []
        if marker_code == 0:
            for marker in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "sequencer", "BISECT_LOG"):
                if (pathlib.Path(git_dir.strip()) / marker).exists(): markers.append(marker)
        self.add("git.in_progress", "unknown" if marker_code else "warn" if markers else "pass", "只stat未完成Git操作marker；有标记时先停更，由本人处理，doctor不abort/continue。", markers=markers)
        code, output, _ = self.git.run("remote", "-v")
        remotes = []
        if code == 0:
            for line in output.splitlines():
                match = re.match(r"(\S+)\s+(.+)\s+\((fetch|push)\)$", line)
                if match:
                    remotes.append({"name": match.group(1), "raw": match.group(2), "direction": match.group(3)})
        expected_url = self.config.get("repo_url")
        remote_name = self.config.get("remote_name") or "origin"
        matching = [r for r in remotes if r["name"] == remote_name and r["direction"] == "fetch"]
        if code:
            remote_status, reason = "unknown", "无法读取仓库remote；未读取全局配置或凭证。"
        elif any(has_url_credentials(r["raw"]) for r in remotes) or has_url_credentials(expected_url):
            remote_status, reason = "fail", "remote含凭据/query/fragment或无效URL；已隐藏输入，先由本人修正安全来源地址。"
        elif expected_url is None:
            remote_status, reason = "unknown", "repo_url为null：列出现有remote，不能确认它是团队仓库。"
        elif not matching:
            remote_status, reason = "fail", "预期remote不存在；未自动添加或改写。"
        else:
            same = all(canonical_remote(r["raw"], self.workspace) == canonical_remote(expected_url, self.workspace) for r in matching)
            remote_status, reason = ("pass", "现有fetch remote与明确给定repo_url匹配。") if same else ("fail", "现有remote与repo_url的协议/主机/端口/路径不匹配；SSH/HTTPS不自动等同，需本人确认后调整本地配置。")
        self.add("git.remotes", remote_status, reason, expected_remote=remote_name, expected_url=safe_url(expected_url) if expected_url else None, remotes=[{"name": r["name"], "url": safe_url(r["raw"]), "direction": r["direction"]} for r in remotes])
        code, branch, _ = self.git.run("symbolic-ref", "--quiet", "--short", "HEAD")
        branch = branch.strip() if code == 0 else None
        base_branch = self.config.get("base_branch")
        self.add("git.branch", "warn" if branch is None else "pass", "detached HEAD，需确认工作基线。" if branch is None else "报告日常当前分支，不强制feature等于base；首次基线由runbook单独验收。", branch=branch, configured_base_branch=base_branch)
        ref_code, ref_output, _ = self.git.run("for-each-ref", "--format=%(refname)%00%(upstream)%00%(upstream:remotename)%00%(upstream:remoteref)", "refs/heads")
        metadata = None
        if ref_code == 0 and branch:
            for line in ref_output.splitlines():
                parts = line.split("\0")
                if len(parts) == 4 and parts[0] == "refs/heads/" + branch:
                    metadata = parts
                    break
        code, upstream, _ = self.git.run("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
        if code:
            self.add("git.upstream.local_distance", "warn", "没有可解析的upstream；不创建或修复跟踪关系。", upstream=None)
            self.add("git.upstream.identity", "unknown", "没有upstream，无法确认远端及目标身份。", expected_upstream=self.config.get("expected_upstream"))
        else:
            upstream = upstream.strip()
            code, counts, _ = self.git.run("rev-list", "--left-right", "--count", "HEAD...@{upstream}")
            pair = counts.split()
            valid = code == 0 and len(pair) == 2 and all(v.isdigit() for v in pair)
            ahead, behind = (int(pair[0]), int(pair[1])) if valid else (None, None)
            self.add("git.upstream.local_distance", "warn" if not valid or ahead or behind else "pass", "只比较本地提交差额；same SHA或0/0不确认upstream身份，且不是服务器实时状态。", upstream=upstream, ahead=ahead, behind=behind, remote_freshness="stale_or_unknown")
            expected_ref = self.config.get("expected_upstream")
            actual_ref, actual_remote, remote_ref = metadata[1:] if metadata else (None, None, None)
            expected_remote_ref = "refs/heads/" + expected_ref[len("refs/remotes/" + remote_name + "/"):] if expected_ref and expected_ref.startswith("refs/remotes/" + remote_name + "/") else None
            if metadata is None:
                identity_status, identity_reason = "unknown", "无法读取完整upstream身份，不以相同提交补推。"
            elif actual_remote != remote_name:
                identity_status, identity_reason = "fail", "upstream跟踪了其他remote或本地(.)；即使same SHA也不等于已确认来源。"
            elif expected_url is None or expected_ref is None or base_branch is None:
                identity_status, identity_reason = "unknown", "repo_url/base_branch/expected_upstream尚未全部确认；不猜测跟踪目标。"
            elif remote_status != "pass":
                identity_status, identity_reason = "fail", "upstream remote的实际URL未通过严格来源核对。"
            elif actual_ref != expected_ref or remote_ref != expected_remote_ref:
                identity_status, identity_reason = "fail", "upstream完整ref或远端分支目标与明确确认值不符；same SHA不能替代身份。"
            else:
                identity_status, identity_reason = "pass", "本地记录的upstream remote、URL、完整ref和远端分支符合明确配置；未联网。"
            self.add("git.upstream.identity", identity_status, identity_reason, actual_ref=actual_ref, remote_name=actual_remote, remote_ref=remote_ref, expected_upstream=expected_ref)
        fetch_metadata = None
        code, common_dir, _ = self.git.run("rev-parse", "--git-common-dir")
        if code == 0:
            path = pathlib.Path(common_dir.strip())
            if not path.is_absolute(): path = self.workspace / path
            fetch = path / "FETCH_HEAD"
            if fetch.is_file():
                fetch_metadata = datetime.datetime.fromtimestamp(fetch.stat().st_mtime, datetime.timezone.utc).isoformat()
        self.add("git.freshness", "unknown", "本doctor不fetch、不访问网络；仅stat FETCH_HEAD，不读取其可能含URL的正文。", last_fetch_file_mtime_utc=fetch_metadata)
        code, output, _ = self.git.run("status", "--porcelain=v1", "-z", "--untracked-files=normal", "--ignore-submodules=all")
        entries = output.split("\0") if code == 0 else []
        dirty = []
        index = 0
        while index < len(entries):
            entry = entries[index]; index += 1
            if not entry: continue
            dirty.append({"status": entry[:2], "path": entry[3:]})
            if "R" in entry[:2] or "C" in entry[:2]: index += 1
        self.add("git.dirty", "unknown" if code else "warn" if dirty else "pass", "只读工作树状态（忽略子模块内部）；未stash/清理/提交。", dirty_count=len(dirty), entries=dirty[:100], entries_truncated=len(dirty) > 100)
        code, output, _ = self.git.run("ls-files", "-z")
        self.tracked = [v for v in output.split("\0") if v] if code == 0 else []
        code, output, _ = self.git.run("ls-files", "--others", "--exclude-standard", "-z")
        self.untracked = [v for v in output.split("\0") if v] if code == 0 else []
        self.check_lfs()

    def check_lfs(self):
        attributes = self.workspace / ".gitattributes"
        declared = []
        if attributes.is_file():
            try:
                declared = [line.strip().split()[0] for line in read_small(attributes).decode("utf-8", "replace").splitlines() if line.strip() and not line.lstrip().startswith("#") and "filter=lfs" in line.split()]
            except (OSError, ValueError):
                pass
        files = list(dict.fromkeys(self.tracked + self.untracked))
        limit = len(files) > SCAN_LIMIT
        files = files[:SCAN_LIMIT]
        matched = []
        code = 0
        if files:
            code, output, _ = self.git.run("check-attr", "-z", "--stdin", "filter", input_bytes=("\0".join(files) + "\0").encode("utf-8"))
            tokens = output.split("\0")
            for index in range(0, len(tokens) - 2, 3):
                if tokens[index + 2] == "lfs": matched.append(tokens[index])
        lfs_code, version, _ = self.git.run("lfs", "version")
        available = lfs_code == 0
        required = bool(matched)
        status = "unknown" if code or limit else "fail" if required and not available else "pass"
        reason = "当前实际文件匹配LFS过滤器；需要现有Git LFS，未执行install/pull。" if required else "当前候选文件没有有效LFS过滤要求；仅声明未来模式不构成当前LFS阻塞。"
        if code or limit: reason = "属性检查失败或文件数超限，不能完整判断LFS需求。"
        self.add("git.lfs", status, reason, attributes_present=attributes.is_file(), declared_patterns=declared[:100], matched_files=matched[:100], matched_count=len(matched), required_for_current_files=required, lfs_available=available, lfs_version=version.strip() if available else None, truncated=limit)

    def check_file_risks(self):
        known = self.tracked + self.untracked
        large = []
        tracked_set = set(self.tracked)
        scanned = 0
        if self.exact_repo:
            candidates = [self.workspace / name for name in known[:SCAN_LIMIT]]
            incomplete = len(known) > SCAN_LIMIT
        else:
            candidates = []
            stack = [(self.workspace, 0)] if self.workspace.is_dir() else []
            while stack and len(candidates) < SCAN_LIMIT:
                folder, depth = stack.pop()
                try:
                    for entry in folder.iterdir():
                        if entry.name in PRUNE_NAMES or entry.name == "local" or entry.is_symlink(): continue
                        if entry.is_file(): candidates.append(entry)
                        elif depth < 5 and entry.is_dir() and entry.name != "Tools": stack.append((entry, depth + 1))
                        if len(candidates) >= SCAN_LIMIT: break
                except OSError: pass
            incomplete = bool(stack)
        for path in candidates:
            try:
                if path.is_symlink() or not path.is_file(): continue
                relative = str(path.relative_to(self.workspace)).replace(os.sep, "/")
                if any(part in PRUNE_NAMES or part == "local" for part in pathlib.PurePosixPath(relative).parts): continue
                scanned += 1
                size = path.stat().st_size
                if size >= DEFAULT_LARGE_MIB * 1024 ** 2:
                    large.append({"path": relative, "bytes": size, "tracked": relative in tracked_set if self.exact_repo else None})
            except (OSError, ValueError): pass
        local_tools = []
        tool_candidates = []
        tools = self.workspace / "game/Tools"
        stack = [(tools, 0)] if tools.is_dir() else []
        inspected = 0
        while stack and inspected < 1000:
            folder, depth = stack.pop()
            try:
                for path in folder.iterdir():
                    inspected += 1
                    if path.is_symlink(): continue
                    if path.suffix.lower() in (".app", ".exe", ".pkg", ".dmg"):
                        tool_candidates.append(path)
                    elif path.is_dir() and depth < 3:
                        stack.append((path, depth + 1))
            except OSError: pass
        for path in tool_candidates:
            relative = str(path.relative_to(self.workspace)).replace(os.sep, "/")
            tracked = any(name == relative or name.startswith(relative + "/") for name in tracked_set) if self.exact_repo else None
            if tracked is not True:
                local_tools.append({"path": relative, "tracked": tracked, "ignored": None, "expected_environment_dependency": True, "contents_scanned": False})
        if local_tools and self.exact_repo:
            code, output, _ = self.git.run("check-ignore", "-z", "--stdin", input_bytes=("\0".join(t["path"] for t in local_tools) + "\0").encode("utf-8"))
            if code in (0, 1):
                ignored = set(output.split("\0"))
                for tool in local_tools: tool["ignored"] = tool["path"] in ignored
        self.add("files.portability", "warn" if large or local_tools or incomplete else "pass", "仅stat文件，不读凭证/源码正文；具体工具包可能是预期环境依赖，不声称整个Tools不可移植，未打包/删除。", large_threshold_mib=DEFAULT_LARGE_MIB, large_files=large[:100], local_untracked_tools=local_tools, scanned_files=scanned, scan_incomplete=incomplete, git_tracking_known=self.exact_repo)

    def run(self):
        self.add("platform", "pass" if platform.system() in ("Darwin", "Windows") else "warn", "仅报告当前OS/CPU；未推断Unity可运行或平台适配通过。", os=platform.system(), os_release=platform.release(), cpu=platform.machine(), python=platform.python_version())
        self.check_project()
        self.check_tools()
        thresholds = self.config.get("disk_thresholds", {})
        self.check_disk("workspace", self.workspace, thresholds.get("workspace_min_free_gib"))
        self.check_disk("editor", self.editor_volume or self.selected_editor, thresholds.get("editor_min_free_gib"))
        self.check_repo()
        self.check_file_risks()
        counts = {status: sum(c["status"] == status for c in self.checks) for status in ("pass", "warn", "fail", "unknown", "not_applicable")}
        exit_code = 1 if counts["fail"] or counts["warn"] or counts["unknown"] else 0
        return {"schema_version": 1, "project": "U R Chinese?!", "generated_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), "read_only": True, "workspace": str(self.workspace), "overall": "needs_attention" if exit_code else "pass", "exit_code": exit_code, "summary": counts, "checks": self.checks, "git_commands_executed": self.git.calls if self.git else []}


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--workspace", help="Choose the intended repository root; never auto-init/clone")
    parser.add_argument("--config", help="Optional explicit setup.example.json/config path")
    parser.add_argument("--editor-path", help="Existing Unity Editor path; metadata inspection only")
    parser.add_argument("--editor-volume", help="Destination directory/volume for a separate free-space check")
    args = parser.parse_args(argv)
    try:
        config = load_config(args.config)
        workspace = args.workspace or config.get("workspace")
        if not workspace:
            raise ConfigurationError("workspace必须由本人通过--workspace或配置明确选择；doctor不猜测目录。")
        report = Doctor(workspace, config, args.editor_path, args.editor_volume).run()
    except ConfigurationError as error:
        report = {"schema_version": 1, "read_only": True, "overall": "configuration_error", "exit_code": 2, "checks": [{"id": "configuration", "status": "fail", "reason": str(error)}]}
    except (OSError, ValueError) as error:
        report = {"schema_version": 1, "read_only": True, "overall": "inspection_error", "exit_code": 2, "checks": [{"id": "inspection", "status": "fail", "reason": "Read-only inspection failed: " + type(error).__name__}]}
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return report["exit_code"]


if __name__ == "__main__":
    sys.exit(main())
