#!/usr/bin/env python3
"""
Marketing-version arithmetic + project-file write helpers.

Extracted from ``mmv_floor_check`` so the floor-check module stays under
the project's 10-functions-per-file cap. Two concerns live here:

  1. ``compute_next_version(current, floor, policy)`` -- pure semver
     arithmetic. Given the project's current MARKETING_VERSION and the
     ASC combined floor, returns the next value that satisfies BOTH:

       result > floor                (so ASC accepts the new row)
       result > current              (so the bump is observable)

     Policy is ``'rollover'`` (default), ``'patch'``, or ``'minor'``.
     Rollover bumps the patch component with carry: at .9 it rolls
     into the next minor (1.0.9 -> 1.1.0), and at minor=9, patch=9 it
     cascades into the next major (1.9.9 -> 2.0.0). Major has no upper
     limit (9.9.9 -> 10.0.0). Patch (legacy) bumps the patch component
     unbounded (1.0.9 -> 1.0.10) -- preserved for backward compat with
     consumers pinning the historical default. Minor bumps the minor
     component and resets patch to 0.

  2. ``write_marketing_version(new_version)`` -- I/O. Writes
     ``new_version`` into the project's source-of-truth. Resolution order:

       a. xcodegen project.yml / project.yaml / Project.yml /
          Project.yaml at the workspace root (the source of truth for
          xcodegen consumers; the generated .xcodeproj is regenerated
          on every build and editing it is futile).
       b. *.xcconfig files referenced from the pbxproj's
          baseConfigurationReference (some projects keep
          MARKETING_VERSION in xcconfig regardless of xcodegen).
       c. project.pbxproj (native xcodeproj workflow without xcodegen).
       d. Info.plist's CFBundleShortVersionString (older / hand-rolled
          projects where the plist holds the version literal).

     When xcodegen is detected (a) but the YAML/xcconfig substitution
     finds nothing, the function returns False with a ``::warning::`` --
     it deliberately does NOT fall through to writing pbxproj because
     xcodegen would wipe the change on the next generate.

     On every successful write, drops a marker file at
     ``$RUNNER_TEMP/swift-app-mv-bumped`` listing the touched path(s).
     The action.yml staging step reads that marker to stage exactly the
     files mmv touched -- never the broader pbxproj/Info.plist diff
     produced by prepare_signing's manual-signing patches.

DESIGN DECISION: this module owns BOTH concerns (arithmetic and write)
because they are tightly cohesive -- the only caller is the auto-bump
branch in ``mmv_floor_check``, which always pairs them. Splitting them
across two modules would force the caller to wire two imports for one
logical operation. Tests still get pure-function isolation via
``compute_next_version``.
"""

from __future__ import annotations

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

# Reuse the same semver shape as mmv_floor_check.SEM_RE so projects with
# 2-component MARKETING_VERSION (e.g. "1.0") parse identically.
_SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
_MARKETING_LINE = re.compile(
    r"^(\s*)MARKETING_VERSION\s*=\s*[^;]+;\s*$", re.MULTILINE,
)
# xcodegen project.yml line shape (YAML key colon, optional quotes).
# Matches "MARKETING_VERSION: 1.0" / "MARKETING_VERSION: '1.0'" /
# "MARKETING_VERSION: \"1.0\"". Indent preserved via group 1.
_YAML_MARKETING_LINE = re.compile(
    r"^(\s*)MARKETING_VERSION\s*:\s*['\"]?[^'\"\s#]+['\"]?\s*$",
    re.MULTILINE,
)
# xcconfig MARKETING_VERSION line: "MARKETING_VERSION = 1.0" with
# optional [sdk=*] / [config=Release] qualifiers.
_XCCONFIG_MARKETING_LINE = re.compile(
    r"^(\s*MARKETING_VERSION(?:\[[^\]]+\])?\s*=\s*)\S+\s*$",
    re.MULTILINE,
)
_XCODEGEN_SPEC_NAMES = (
    "project.yml", "project.yaml", "Project.yml", "Project.yaml",
)
# Marker file the action.yml staging step reads to know which project
# files to stage. Empty / missing marker means no auto-bump fired and
# nothing project-side should be staged. Located under RUNNER_TEMP so
# it does not pollute the repo and is wiped between runs.
_BUMP_MARKER_NAME = "swift-app-mv-bumped"


def _parse(version: str) -> tuple[int, int, int]:
    """Parse 'M.m[.p]' -> (M, m, p). Raises ValueError on non-semver."""
    m = _SEM_RE.match(version or "")
    if not m:
        raise ValueError(f"not a semver: {version!r}")
    return (int(m.group(1)), int(m.group(2)), int(m.group(3) or "0"))


def compute_next_version(current: str, floor: str, policy: str) -> str:
    """Compute the next MARKETING_VERSION that satisfies both:
       result > floor    AND    result > current.

    ``policy`` is ``'rollover'`` (default), ``'patch'``, or ``'minor'``.
    The ``'none'`` case is handled by the caller (it preserves the
    existing fail-the-build semantics) and never reaches this function.

    Cross-train semantics (shared by 'patch' and 'rollover'): stay on
    the higher major.minor train of (current, floor) so we never
    silently advance an unrelated minor or major; when trains match,
    base patch is max(current.patch, floor.patch). 'rollover' adds
    carry: patch>9 rolls to patch=0, minor+=1; resulting minor>9
    rolls to minor=0, major+=1; major has no upper limit (9.9.9 ->
    10.0.0 just keeps growing). The ``>9`` (not ``==10``) check
    handles inputs already past the rollover boundary -- e.g. a
    project previously on the 'patch' policy that reached 1.0.10
    before switching to 'rollover' must still cascade
    (1.0.10 -> 1.1.0) rather than emit a malformed 1.0.11. 'patch'
    (legacy) is unbounded (1.0.9 -> 1.0.10), preserved verbatim for
    backward compat with consumers that pinned the historical default.
    """
    if policy not in ("rollover", "patch", "minor"):
        raise ValueError(f"unknown policy: {policy!r}")
    cur, fl = _parse(current), _parse(floor)
    if policy == "minor":
        major = max(cur[0], fl[0])
        return f"{major}.{max(cur[1], fl[1]) + 1}.0"
    # patch / rollover share train resolution.
    if cur[:2] >= fl[:2]:
        major, minor, patch_base = cur[0], cur[1], cur[2]
        if cur[:2] == fl[:2]:
            patch_base = max(cur[2], fl[2])
    else:
        major, minor, patch_base = fl
    if policy == "patch":
        return f"{major}.{minor}.{patch_base + 1}"
    # rollover: patch+1 with carry into minor, then into major.
    # ``>9`` (not ``==10``) so inputs already over the boundary
    # (e.g. a 1.0.10 left over from the legacy 'patch' policy)
    # still cascade cleanly into the next minor / major train.
    new_patch = patch_base + 1
    if new_patch > 9:
        new_patch, minor = 0, minor + 1
    if minor > 9:
        minor, major = 0, major + 1
    return f"{major}.{minor}.{new_patch}"


def _xcodebuild_args() -> tuple[list[str], Path]:
    """Mirror resolve_marketing_version._xcodebuild_args() so we resolve
    Info.plist relative to the same project base."""
    workspace = os.environ.get("WORKSPACE", "").strip()
    project = os.environ.get("PROJECT", "").strip()
    if workspace:
        return ["-workspace", workspace], Path(workspace).parent
    return ["-project", project], Path(project).parent


def _resolve_pbxproj_path() -> Path | None:
    """Return the project.pbxproj path, or None when only a workspace is
    set (in that case the workspace points at one or more xcodeproj files
    we cannot disambiguate without parsing it -- caller falls back to
    Info.plist)."""
    project = os.environ.get("PROJECT", "").strip()
    if not project:
        return None
    pbx = Path(project) / "project.pbxproj"
    return pbx if pbx.is_file() else None


def _record_bumped_path(path: Path) -> None:
    """Capture the bumped file in git's index AND drop a marker line for
    the action.yml staging step.

    Why immediate ``git add``: prepare_signing.py mutates pbxproj later
    in the pipeline (manual-signing settings, profile specifiers).
    Staging the *current* file content NOW snapshots the post-bump,
    pre-prepare_signing state in the index. The signing edits hit the
    working tree only; ``git diff --cached`` keeps showing exactly the
    version bump until something explicitly re-stages.

    The marker file is supplementary: it lets the staging step verify
    auto-bump fired and surface the touched paths in CI logs even when
    the immediate ``git add`` is a no-op (e.g. local dev runs where
    RUNNER_TEMP is set but the path is gitignored)."""
    try:
        subprocess.run(
            ["git", "add", "-f", "--", str(path)],
            check=False, capture_output=True, timeout=30,
        )
    except (OSError, subprocess.SubprocessError) as exc:
        print(
            f"[auto-bump] git add failed for {path}: {exc!r}",
            file=sys.stderr,
        )
    runner_temp = os.environ.get("RUNNER_TEMP", "").strip()
    if not runner_temp:
        return
    marker = Path(runner_temp) / _BUMP_MARKER_NAME
    try:
        with open(marker, "a", encoding="utf-8") as fh:
            fh.write(f"{path}\n")
    except OSError as exc:
        print(
            f"[auto-bump] could not append to {marker}: {exc!r}",
            file=sys.stderr,
        )


def _write_yaml_marketing_version(path: Path, new_version: str) -> bool:
    """Regex-rewrite the FIRST ``MARKETING_VERSION:`` key in a YAML
    spec. Preserves indentation and surrounding formatting. Returns True
    on substitution, False when the key is not present in this file."""
    text = path.read_text(encoding="utf-8")
    new_text, count = _YAML_MARKETING_LINE.subn(
        rf"\g<1>MARKETING_VERSION: {new_version}", text,
    )
    if count == 0:
        return False
    path.write_text(new_text, encoding="utf-8")
    print(
        f"[auto-bump] wrote MARKETING_VERSION={new_version} to "
        f"{path} ({count} occurrence(s))",
        file=sys.stderr,
    )
    _record_bumped_path(path)
    return True


def _write_xcconfig_marketing_version(new_version: str) -> bool:
    """Rewrite ``MARKETING_VERSION = X`` in the first xcconfig file that
    declares the key. Walks the project's parent directory
    non-recursively for top-level configs and one level down (covers
    common ``Configs/`` / ``BuildConfig/`` layouts). Returns True on
    the first successful write, False when no xcconfig declares the
    key."""
    _proj_args, base = _xcodebuild_args()
    if not base.is_dir():
        return False
    candidates: list[Path] = list(sorted(base.glob("*.xcconfig")))
    for sub in sorted(p for p in base.iterdir() if p.is_dir()):
        candidates.extend(sorted(sub.glob("*.xcconfig")))
    for path in candidates:
        try:
            text = path.read_text(encoding="utf-8")
        except OSError:
            continue
        new_text, count = _XCCONFIG_MARKETING_LINE.subn(
            rf"\g<1>{new_version}", text,
        )
        if count == 0:
            continue
        path.write_text(new_text, encoding="utf-8")
        print(
            f"[auto-bump] wrote MARKETING_VERSION={new_version} to "
            f"{path} ({count} occurrence(s))",
            file=sys.stderr,
        )
        _record_bumped_path(path)
        return True
    return False


def _write_pbxproj_marketing_version(pbx: Path, new_version: str) -> bool:
    """Rewrite every ``MARKETING_VERSION = ...;`` line in pbxproj to
    ``MARKETING_VERSION = <new_version>;``. Returns True when at least
    one substitution happened, False otherwise.

    SAFETY: caller MUST have written the xcodegen spec first when one
    exists. On its own this edit is futile for a generated pbxproj --
    the next ``xcodegen generate`` wipes it -- so with a spec present
    it is only ever a *sync* of a value already persisted in the real
    source of truth, never a substitute for writing that source."""
    text = pbx.read_text(encoding="utf-8")
    new_text, count = _MARKETING_LINE.subn(
        rf"\g<1>MARKETING_VERSION = {new_version};", text,
    )
    if count == 0:
        return False
    pbx.write_text(new_text, encoding="utf-8")
    print(
        f"[auto-bump] wrote MARKETING_VERSION={new_version} to "
        f"{pbx} ({count} build configuration(s))",
        file=sys.stderr,
    )
    _record_bumped_path(pbx)
    return True


_INFOPLIST_FILE_RE = re.compile(
    r"^\s*INFOPLIST_FILE\s*=\s*(.+?)\s*$", re.MULTILINE,
)


def _write_infoplist_short_version(new_version: str) -> bool:
    """Fallback: stamp CFBundleShortVersionString into the project's
    resolved Info.plist. Returns True on success, False when no plist
    resolves."""
    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=120,
        )
    except (OSError, subprocess.SubprocessError):
        return False
    m = _INFOPLIST_FILE_RE.search(out.stdout or "")
    if not m:
        return False
    rel = m.group(1).strip()
    for path in (base / rel, Path(rel)):
        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
        data["CFBundleShortVersionString"] = new_version
        with open(path, "wb") as fh:
            plistlib.dump(data, fh)
        print(
            f"[auto-bump] wrote CFBundleShortVersionString={new_version} "
            f"to {path}", file=sys.stderr,
        )
        _record_bumped_path(path)
        return True
    return False


_XCODEGEN_REFUSE_MSG = (
    "::warning::auto-bump: xcodegen project.yml detected but "
    "MARKETING_VERSION not found in YAML or xcconfig. Refusing to "
    "edit the generated pbxproj (would be wiped by the next "
    "`xcodegen generate`). Add `settings: { base: { "
    "MARKETING_VERSION: <value> } }` to project.yml or pin "
    "`marketing-version-auto-bump: 'none'`."
)


def write_marketing_version(new_version: str) -> bool:
    """Persist ``new_version`` into the project source-of-truth.

    Resolution order is xcodegen-aware: project.yml -> xcconfig ->
    project.pbxproj (only without a spec) -> Info.plist. Records every
    successful write to ``$RUNNER_TEMP/<marker>`` so the action.yml
    staging step picks up exactly what was touched (never
    prepare_signing's pbxproj diff). Returns True on the first
    successful write, False when no source of truth resolved."""
    _proj_args, base = _xcodebuild_args()
    spec_bases = {Path(".").resolve()}
    if base.exists():
        spec_bases.add(base.resolve())
    specs = [d / name for d in spec_bases for name in _XCODEGEN_SPEC_NAMES
             if (d / name).is_file()]
    for spec in specs:
        if _write_yaml_marketing_version(spec, new_version):
            # Repos that commit BOTH the spec and the generated .xcodeproj
            # archive from the pbxproj -- auto_detect only runs `xcodegen
            # generate` when no .xcodeproj is present -- so the spec write
            # alone would never reach xcodebuild. Sync the pbxproj too; a
            # later regenerate produces the same value either way.
            pbx = _resolve_pbxproj_path()
            if pbx is not None:
                _write_pbxproj_marketing_version(pbx, new_version)
            return True
    if _write_xcconfig_marketing_version(new_version):
        return True
    if specs:
        print(_XCODEGEN_REFUSE_MSG, file=sys.stderr)
        return False
    pbx = _resolve_pbxproj_path()
    if pbx is not None and _write_pbxproj_marketing_version(pbx, new_version):
        return True
    return _write_infoplist_short_version(new_version)
