#!/usr/bin/python3
# SPDX-License-Identifier: LicenseRef-Proprietary
"""Upgrade stock launcher shortcuts without replacing a user's compositor settings."""

from copy import deepcopy
import os
from pathlib import Path
import re
import stat
import sys
import tempfile
import tomllib


STRINGS = re.compile(r'(?=(?P<literal>"(?:[^"\\]|\\.)*"|\'[^\']*\'))', re.S)


def launcher_config(text):
    """Change only Super/Super+Space actions equal to the previous stock fuzzel action."""
    current = tomllib.loads(text)
    bindings = current.get('bindings', {})
    if not isinstance(bindings, dict):
        return text
    targets = [key for key in ('Super', 'Super+Space') if bindings.get(key) == {'spawn': ['fuzzel']}]
    for key in targets:
        expected = deepcopy(current)
        expected['bindings'][key] = {'spawn': ['gravitas-launcher']}
        # Trying parsed candidates keeps comments, whitespace, unrelated strings, quoted keys
        # and alternate TOML table forms intact without treating a regex as a TOML parser.
        for match in STRINGS.finditer(text):
            try:
                if tomllib.loads('value = ' + match.group('literal'))['value'] != 'fuzzel':
                    continue
                changed = text[:match.start('literal')] + '"gravitas-launcher"' + text[match.end('literal'):]
                parsed = tomllib.loads(changed)
            except tomllib.TOMLDecodeError:
                continue
            if parsed == expected:
                text, current = changed, parsed
                break
        else:
            raise ValueError(f'Cannot safely update the stock {key} binding; edit it manually if desired.')
    return text


def migrate(configured):
    """Atomically update a regular config, retaining its symlink, mode and first backup."""
    if not configured.exists() and not configured.is_symlink():
        return False
    path = configured.resolve(strict=True)
    metadata = path.stat()
    if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid():
        raise ValueError('The compositor config must be a regular file owned by the login user.')
    original = path.read_bytes()
    changed = launcher_config(original.decode('utf-8')).encode('utf-8')
    if changed == original:
        return False
    backup = path.with_name(path.name + '.pre-launcher')
    try:
        descriptor = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    except FileExistsError:
        pass
    else:
        with os.fdopen(descriptor, 'wb') as stream:
            os.fchmod(stream.fileno(), stat.S_IMODE(metadata.st_mode))
            stream.write(original)
            stream.flush()
            os.fsync(stream.fileno())
    with tempfile.NamedTemporaryFile(mode='wb', dir=path.parent, delete=False) as stream:
        temporary = Path(stream.name)
        try:
            stream.write(changed)
            stream.flush()
            os.fsync(stream.fileno())
            os.fchmod(stream.fileno(), stat.S_IMODE(metadata.st_mode))
            latest = path.stat()
            if (latest.st_dev, latest.st_ino, latest.st_mtime_ns, latest.st_ctime_ns, latest.st_size) != (
                    metadata.st_dev, metadata.st_ino, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_size) or path.read_bytes() != original:
                raise RuntimeError('Settings changed during launcher migration; try again at the next login.')
            os.replace(temporary, path)
        finally:
            temporary.unlink(missing_ok=True)
    return True


def main():
    path = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'gravitas/compositor.toml'
    try:
        if migrate(path):
            print('Gravitas: upgraded stock app-launcher shortcuts; original config saved with .pre-launcher.')
    except (OSError, ValueError, RuntimeError) as error:
        # An optional default migration must not prevent the existing session from starting.
        print(f'Gravitas launcher migration: {error}', file=sys.stderr)


if __name__ == '__main__':
    main()
