#!/usr/bin/env python3
"""
Resolve the project's marketing version from build settings or Info.plist.

The project's MARKETING_VERSION is the single source of truth for the
marketing version. CI does not bump it. This script reads it from one of:
  1. ``xcodebuild -showBuildSettings`` (looking for ``MARKETING_VERSION``).
  2. ``CFBundleShortVersionString`` in the resolved Info.plist (fallback
     for projects that hardcode the marketing version in the plist
     instead of using the build setting).

Stdout: ``MARKETING_VERSION=<value>`` (suitable for piping into
        ``$GITHUB_ENV``).
Stderr: ``Resolved MARKETING_VERSION=<value> (source: <source>)`` on
        success, or a single-line ``::error::...`` message on failure.

Exit codes:
  0 -- resolved a valid semver value.
  1 -- could not resolve a valid value; user must set it in their
       project's build settings or Info.plist.
"""

from __future__ import annotations

import os
import plistlib
import re
import subprocess
import sys
from pathlib import Path

SEM_RE = re.compile(r"^\d+\.\d+(\.\d+)?$")
_MIGRATION_HINT = (
    "See .github/actions/swift-app/MIGRATION.md for the "
    "migration procedure."
)


def _xcodebuild_args() -> tuple[list[str], Path]:
    """Return (xcodebuild project/workspace args, project base dir).

    Prefer the app -project over the -workspace when both are known.
    MARKETING_VERSION is an app-target build setting, so reading it from the
    bare project is sufficient AND avoids evaluating the entire CocoaPods
    workspace graph (Firebase + use_frameworks! ~= 50 pods), which can push
    `xcodebuild -showBuildSettings` past its timeout on CI runners. Falls
    back to the workspace when no project path is provided.
    """
    workspace = os.environ.get("WORKSPACE", "").strip()
    project = os.environ.get("PROJECT", "").strip()
    if project:
        return ["-project", project], Path(project).parent
    return ["-workspace", workspace], Path(workspace).parent


def _read_build_settings() -> str:
    """Run xcodebuild -showBuildSettings; return stdout (empty on error)."""
    proj_args, _base = _xcodebuild_args()
    scheme = os.environ.get("SCHEME", "").strip()
    config = os.environ.get("CONFIGURATION", "").strip() or "Release"
    cmd = ["xcodebuild", *proj_args, "-scheme", scheme,
           "-configuration", config, "-showBuildSettings"]
    try:
        out = subprocess.run(
            cmd, check=False, capture_output=True, text=True, timeout=300,
        )
    except (OSError, subprocess.SubprocessError):
        return ""
    return out.stdout or ""


def _grep_setting(settings: str, key: str) -> str:
    """Pull `<KEY> = <value>` out of xcodebuild -showBuildSettings output."""
    pattern = re.compile(rf"^\s*{re.escape(key)}\s*=\s*(.+?)\s*$", re.MULTILINE)
    m = pattern.search(settings)
    return m.group(1).strip() if m else ""


_VAR_REF_RE = re.compile(r"\$[({]([A-Za-z_][A-Za-z0-9_]*)(?::[^)}]*)?[)}]")


def _expand_setting_refs(value: str, settings: str) -> str:
    """Expand $(VAR)/${VAR} references against -showBuildSettings output.

    Flutter's iOS template ships Info.plist with
    ``CFBundleShortVersionString = $(FLUTTER_BUILD_NAME)`` and no
    MARKETING_VERSION on the target; the actual version lives in
    ios/Flutter/Generated.xcconfig (written by ``flutter build ios
    --config-only``) and therefore appears in the build settings. Reading
    the raw plist yields the unexpanded reference, so expand it here.
    Returns "" when any referenced variable cannot be resolved.
    """
    unresolved = False

    def _sub(match: re.Match) -> str:
        nonlocal unresolved
        resolved = _grep_setting(settings, match.group(1))
        if not resolved:
            unresolved = True
        return resolved

    expanded = _VAR_REF_RE.sub(_sub, value)
    return "" if unresolved else expanded.strip()


def _read_plist_short_version(settings: str) -> tuple[str, str]:
    """(version_or_empty, source_label). Resolves INFOPLIST_FILE relative
    to the project base when the setting holds a relative path."""
    infoplist = _grep_setting(settings, "INFOPLIST_FILE")
    if not infoplist:
        return "", ""
    _proj_args, base = _xcodebuild_args()
    candidates = [base / infoplist, Path(infoplist)]
    for path in candidates:
        if not path.is_file():
            continue
        try:
            with open(path, "rb") as fh:
                data = plistlib.load(fh)
        except (OSError, plistlib.InvalidFileException, ValueError):
            continue
        if not isinstance(data, dict):
            continue
        value = (data.get("CFBundleShortVersionString") or "").strip()
        if not value:
            continue
        if "$(" in value or "${" in value:
            expanded = _expand_setting_refs(value, settings)
            if not expanded:
                continue
            return expanded, (
                f"Info.plist CFBundleShortVersionString in {path} "
                f"({value} expanded via build settings)"
            )
        return value, f"Info.plist CFBundleShortVersionString in {path}"
    return "", ""


def _resolve() -> tuple[str, str]:
    """(value, source). Empty value means resolution failed."""
    settings = _read_build_settings()
    mv = _grep_setting(settings, "MARKETING_VERSION")
    if mv:
        return mv, "xcodebuild -showBuildSettings (MARKETING_VERSION)"
    return _read_plist_short_version(settings)


def _emit_error() -> None:
    scheme = os.environ.get("SCHEME", "").strip()
    config = os.environ.get("CONFIGURATION", "").strip() or "Release"
    print(
        f"::error::Could not resolve a valid MARKETING_VERSION for "
        f"scheme={scheme} configuration={config}. Set MARKETING_VERSION "
        f"in project.yml [xcodegen], the .xcodeproj's MARKETING_VERSION "
        f"xcconfig, or CFBundleShortVersionString in Info.plist (must "
        f"match ^\\d+\\.\\d+(\\.\\d+)?$). CI no longer bumps the marketing "
        f"version automatically. " + _MIGRATION_HINT,
        file=sys.stderr,
    )


def main() -> None:
    value, source = _resolve()
    if not value or not SEM_RE.match(value):
        _emit_error()
        raise SystemExit(1)
    print(f"MARKETING_VERSION={value}")
    print(f"Resolved MARKETING_VERSION={value} (source: {source})",
          file=sys.stderr)


if __name__ == "__main__":
    main()
