#!/usr/bin/env python3
"""
Xcode project / scheme / bundle_id auto-detection.

Ported from gowalk-step/fastlane_standalone.sh — keeps us in Python (our
ecosystem) while still shelling out to ``xcodebuild`` for the two queries
only it can reliably answer:

    ``xcodebuild -list -json``                  -> enumerate schemes
    ``xcodebuild -showBuildSettings -json``     -> PRODUCT_TYPE + bundle id

The detection lets ``ci.config.yaml`` become entirely optional: with just
``creds/AuthKey_*.p8`` + the 18-line ``deploy.yml`` the pipeline can still
figure out what to build.

Rules (kept intentionally boring — if auto-detect is ambiguous we log a
warning and return a deterministic choice, falling back to a clear
``::error::`` only when truly nothing works):

1. ``auto_detect_project(workspace)``
   - Prefer a single ``*.xcworkspace`` at the repo root.
   - Else a single ``*.xcodeproj``.
   - When multiple ``.xcodeproj`` exist, prefer one matching the repo
     basename, else alphabetically first (with a warning).

2. ``auto_detect_scheme(workspace, project, workspace_file)``
   - ``xcodebuild -list -json`` to enumerate schemes.
   - For each scheme, ``-showBuildSettings -json`` and keep those whose
     ``PRODUCT_TYPE == com.apple.product-type.application``.
   - Single application scheme -> that one. Multiple: prefer repo-basename
     match, else alphabetical first.

3. ``auto_detect_bundle_id(workspace, project, workspace_file, scheme, configuration)``
   - Read ``PRODUCT_BUNDLE_IDENTIFIER`` from the same ``-showBuildSettings``
     invocation (cached from step 2 when possible).
   - Reject unresolved variable references like ``$(PRODUCT_NAME)``.

All xcodebuild invocations are cached per-process so repeated calls in the
same ``read_config.py`` run don't re-shell.
"""

from __future__ import annotations

import json
import subprocess
from pathlib import Path
from typing import Optional

from cfg_io import log, notice
import native_dependency_guard


APP_PRODUCT_TYPE = "com.apple.product-type.application"
# Timeouts (seconds) — xcodebuild can hang on missing toolchains or
# package resolution. CI should fail fast rather than spin forever.
#
# The first xcodebuild invocation on a cold runner also resolves the Swift
# Package Manager graph, and a project that depends on firebase-ios-sdk spends
# minutes cloning before it prints a single scheme. That is legitimate work,
# not a hang, so `-list` gets a budget that survives it and a one-shot
# pre-resolution retry (see _list_schemes) rather than a tighter deadline.
LIST_TIMEOUT = 180
SETTINGS_TIMEOUT = 300
RESOLVE_TIMEOUT = 900

# Process-local caches keyed by (project, workspace_file) or
# (project, workspace_file, scheme, configuration). Cleared between test
# cases via ``_clear_caches`` but otherwise persist for the lifetime of
# the read_config.py process.
_list_cache: dict[tuple, Optional[list[str]]] = {}
_settings_cache: dict[tuple, Optional[dict]] = {}


def _clear_caches() -> None:
    """Reset caches — used by tests. Not part of the public API."""
    _list_cache.clear()
    _settings_cache.clear()


def _find_xcodegen_spec(workspace: Path) -> Path | None:
    """Locate an xcodegen spec file (project.yml / Project.yml / project.yaml).

    xcodegen accepts any of these names. We check them in this order. Case
    mismatch on case-sensitive filesystems has bitten us before.
    """
    for candidate in ("project.yml", "project.yaml", "Project.yml", "Project.yaml"):
        path = workspace / candidate
        if path.is_file():
            return path
    return None


def auto_detect_project(workspace: Path) -> tuple[str, str]:
    """Discover the repo-root Xcode project / workspace.

    Returns ``(project, workspace_file)`` where exactly one is a non-empty
    filename. ``("", "")`` means nothing was found.

    If no ``.xcworkspace``/``.xcodeproj`` is present but a ``project.yml``
    exists (xcodegen spec), we invoke ``xcodegen generate`` once to
    materialize the ``.xcodeproj`` and retry. xcodegen-based projects
    commonly gitignore the generated ``.xcodeproj``, so the runner sees
    only the spec.
    """
    log(f"auto-detect: scanning workspace={workspace}")
    workspaces = sorted(workspace.glob("*.xcworkspace"))
    if workspaces:
        picked = _pick_by_basename(workspaces, workspace, "xcworkspace")
        log(f"auto-detect: workspace={picked.name}")
        return "", picked.name

    projects = sorted(workspace.glob("*.xcodeproj"))
    if not projects:
        spec = _find_xcodegen_spec(workspace)
        if spec is not None:
            log(f"auto-detect: xcodegen spec found at {spec.name}")
            if _run_xcodegen(workspace):
                projects = sorted(workspace.glob("*.xcodeproj"))
        else:
            # Surface what we DID see so CI logs are actionable when the
            # expected spec file is missing / misnamed / gitignored.
            entries = sorted(p.name for p in workspace.iterdir() if not p.name.startswith("."))
            log(
                f"auto-detect: no .xcodeproj / .xcworkspace / project.yml at "
                f"{workspace}; root entries={entries[:20]}"
            )

    if not projects:
        return "", ""
    picked = _pick_by_basename(projects, workspace, "xcodeproj")
    log(f"auto-detect: project={picked.name}")
    return picked.name, ""


def _run_xcodegen(workspace: Path) -> bool:
    """Run ``xcodegen generate`` in ``workspace``. Returns True on success.

    Logs a notice on failure but never raises — the caller will see an
    empty project/workspace result and the normal ``::error::`` path
    will guide the user.
    """
    log("auto-detect: no .xcodeproj/.xcworkspace found, xcodegen spec present — running xcodegen")
    try:
        result = subprocess.run(
            ["xcodegen", "generate"],
            cwd=str(workspace),
            capture_output=True,
            text=True,
            timeout=LIST_TIMEOUT,
        )
    except FileNotFoundError:
        notice(
            "xcodegen not on PATH — installing via Homebrew (one-shot). "
            "If this fails, pre-install xcodegen or commit the generated .xcodeproj."
        )
        if not _install_xcodegen():
            return False
        try:
            result = subprocess.run(
                ["xcodegen", "generate"],
                cwd=str(workspace),
                capture_output=True,
                text=True,
                timeout=LIST_TIMEOUT,
            )
        except (OSError, subprocess.TimeoutExpired) as exc:
            notice(f"xcodegen still unavailable after install: {exc!r}")
            return False
    except (OSError, subprocess.TimeoutExpired) as exc:
        notice(
            f"xcodegen not available or timed out ({exc!r}); "
            "install xcodegen or commit the generated .xcodeproj."
        )
        return False

    if result.returncode != 0:
        notice(
            f"xcodegen generate failed (rc={result.returncode}): "
            f"{result.stderr.strip() or result.stdout.strip()}"
        )
        return False

    log("auto-detect: xcodegen generate succeeded")
    return True


def _install_xcodegen() -> bool:
    """Install xcodegen via Homebrew. Returns True on success.

    Called only when `xcodegen` is missing from PATH. macOS GitHub runners
    normally preinstall it, but when they don't we need a self-bootstrap
    path — otherwise the entire action fails at auto-detect for
    xcodegen-based projects with no actionable error.
    """
    try:
        result = subprocess.run(
            ["brew", "install", "xcodegen"],
            capture_output=True,
            text=True,
            timeout=300,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        notice(f"brew install xcodegen failed: {exc!r}")
        return False

    if result.returncode != 0:
        notice(
            f"brew install xcodegen failed (rc={result.returncode}): "
            f"{result.stderr.strip() or result.stdout.strip()}"
        )
        return False

    log("auto-detect: xcodegen installed via Homebrew")
    return True


def _pick_by_basename(matches: list[Path], workspace: Path, label: str) -> Path:
    """Pick the match whose name stem equals the repo basename, else first."""
    if len(matches) == 1:
        return matches[0]
    repo_base = workspace.resolve().name.lower()
    for match in matches:
        if match.stem.lower() == repo_base:
            return match
    notice(
        f"multiple *.{label} found; picking {matches[0].name} alphabetically. "
        f"Set xcode.project in ci.config.yaml to disambiguate."
    )
    return matches[0]


def _resolve_package_dependencies(
    workspace: Path, project: str, workspace_file: str
) -> bool:
    """Retain local Xcode recovery only after refusing an unverified package graph."""
    native_dependency_guard.check(workspace, workspace_file or project)
    cmd = ["xcodebuild", "-resolvePackageDependencies"]
    if workspace_file:
        cmd += ["-workspace", workspace_file]
    elif project:
        cmd += ["-project", project]
    else:
        return False
    log("auto-detect: pre-resolving Swift package dependencies")
    try:
        result = subprocess.run(
            cmd, cwd=str(workspace), capture_output=True, text=True,
            timeout=RESOLVE_TIMEOUT,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        log(f"auto-detect: xcodebuild -resolvePackageDependencies failed: {exc!r}")
        return False
    if result.returncode != 0:
        log(
            f"auto-detect: xcodebuild -resolvePackageDependencies returned "
            f"{result.returncode}: {result.stderr.strip()[:500]}"
        )
    return True


def _list_schemes(
    workspace: Path, project: str, workspace_file: str
) -> Optional[list[str]]:
    """Run ``xcodebuild -list -json`` and return the schemes list (cached)."""
    key = (project, workspace_file)
    if key in _list_cache:
        return _list_cache[key]

    native_dependency_guard.check(workspace, workspace_file or project)
    cmd = ["xcodebuild", "-list", "-json", "-disableAutomaticPackageResolution", "-skipPackageUpdates"]
    if workspace_file:
        cmd += ["-workspace", workspace_file]
    elif project:
        cmd += ["-project", project]
    else:
        _list_cache[key] = None
        return None

    try:
        result = subprocess.run(
            cmd, cwd=str(workspace), capture_output=True, text=True,
            timeout=LIST_TIMEOUT,
        )
    except subprocess.TimeoutExpired as exc:
        # Almost always SPM resolution rather than a hang: `-list` implicitly
        # resolves the package graph, and a cold Firebase/GoogleSignIn clone
        # outlasts any deadline short enough to still catch a real hang. Pay
        # for the resolve once, explicitly, then retry. Swallowing this is what
        # produced the downstream "MARKETING_VERSION for scheme=" failure --
        # an empty scheme reads as "project has none", not "we never asked".
        log(f"auto-detect: xcodebuild -list timed out: {exc!r}")
        if not _resolve_package_dependencies(workspace, project, workspace_file):
            _list_cache[key] = None
            return None
        try:
            result = subprocess.run(
                cmd, cwd=str(workspace), capture_output=True, text=True,
                timeout=LIST_TIMEOUT,
            )
        except (OSError, subprocess.TimeoutExpired) as retry_exc:
            log(f"auto-detect: xcodebuild -list failed after resolve: {retry_exc!r}")
            _list_cache[key] = None
            return None
    except OSError as exc:
        log(f"auto-detect: xcodebuild -list failed: {exc!r}")
        _list_cache[key] = None
        return None

    if result.returncode != 0:
        log(
            f"auto-detect: xcodebuild -list returned {result.returncode}: "
            f"{result.stderr.strip()}"
        )
        _list_cache[key] = None
        return None

    try:
        data = json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        log(f"auto-detect: xcodebuild -list produced invalid JSON: {exc!r}")
        _list_cache[key] = None
        return None

    # Workspace output has {"workspace": {"schemes": [...]}}; project has
    # {"project": {"schemes": [...]}}.
    container = data.get("workspace") or data.get("project") or {}
    schemes = container.get("schemes") or []
    _list_cache[key] = list(schemes)
    return _list_cache[key]


def _show_build_settings(
    workspace: Path,
    project: str,
    workspace_file: str,
    scheme: str,
    configuration: str,
) -> Optional[dict]:
    """Run ``xcodebuild -showBuildSettings -json`` and return buildSettings (cached)."""
    key = (project, workspace_file, scheme, configuration or "Release")
    if key in _settings_cache:
        return _settings_cache[key]

    native_dependency_guard.check(workspace, workspace_file or project)
    cmd = ["xcodebuild", "-showBuildSettings", "-json", "-scheme", scheme,
           "-disableAutomaticPackageResolution", "-skipPackageUpdates"]
    if configuration:
        cmd += ["-configuration", configuration]
    if workspace_file:
        cmd += ["-workspace", workspace_file]
    elif project:
        cmd += ["-project", project]
    else:
        _settings_cache[key] = None
        return None

    try:
        result = subprocess.run(
            cmd, cwd=str(workspace), capture_output=True, text=True,
            timeout=SETTINGS_TIMEOUT,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        log(f"auto-detect: showBuildSettings({scheme!r}) failed: {exc!r}")
        _settings_cache[key] = None
        return None

    if result.returncode != 0:
        log(
            f"auto-detect: showBuildSettings({scheme!r}) returned "
            f"{result.returncode}: {result.stderr.strip()}"
        )
        _settings_cache[key] = None
        return None

    try:
        data = json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        log(f"auto-detect: showBuildSettings({scheme!r}) invalid JSON: {exc!r}")
        _settings_cache[key] = None
        return None

    if not isinstance(data, list) or not data:
        _settings_cache[key] = None
        return None
    settings = data[0].get("buildSettings") or {}
    _settings_cache[key] = settings if isinstance(settings, dict) else None
    return _settings_cache[key]


def auto_detect_scheme(
    workspace: Path, project: str, workspace_file: str
) -> Optional[str]:
    """Pick the single ``com.apple.product-type.application`` scheme."""
    schemes = _list_schemes(workspace, project, workspace_file)
    if not schemes:
        return None

    app_schemes: list[str] = []
    for scheme in schemes:
        settings = _show_build_settings(
            workspace, project, workspace_file, scheme, "Release",
        )
        if not settings:
            continue
        if settings.get("PRODUCT_TYPE") == APP_PRODUCT_TYPE:
            app_schemes.append(scheme)

    if not app_schemes:
        return None
    if len(app_schemes) == 1:
        log(f"auto-detect: scheme={app_schemes[0]}")
        return app_schemes[0]

    repo_base = workspace.resolve().name.lower()
    for scheme in app_schemes:
        if scheme.lower() == repo_base:
            log(f"auto-detect: scheme={scheme} (basename match)")
            return scheme

    picked = sorted(app_schemes)[0]
    notice(
        f"multiple application schemes found ({', '.join(sorted(app_schemes))}); "
        f"picking {picked} alphabetically. Set xcode.scheme in ci.config.yaml "
        f"to disambiguate."
    )
    return picked


def auto_detect_bundle_id(
    workspace: Path,
    project: str,
    workspace_file: str,
    scheme: str,
    configuration: str,
) -> Optional[str]:
    """Extract ``PRODUCT_BUNDLE_IDENTIFIER`` for the given scheme/config."""
    settings = _show_build_settings(
        workspace, project, workspace_file, scheme, configuration or "Release",
    )
    if not settings:
        return None
    bundle = settings.get("PRODUCT_BUNDLE_IDENTIFIER")
    if not bundle or not isinstance(bundle, str):
        return None
    # xcodebuild sometimes emits unresolved variable references when build
    # settings depend on xcconfig files that aren't in the default context.
    if "$(" in bundle or bundle.startswith("$") or "=" in bundle:
        log(f"auto-detect: bundle_id {bundle!r} contains unresolved variable — rejecting")
        return None
    log(f"auto-detect: bundle_id={bundle}")
    return bundle
