#!/usr/bin/env bash
# Requires Bash 3.2+, Python 3.8+, and curl. No sudo; run one installer at a time.
set -euo pipefail

# Defining the complete function before calling it also avoids starting an
# installation from a download truncated inside the embedded Python program.
main() {
    command -v python3 >/dev/null || { printf '%s\n' 'Python 3.8+ is required.' >&2; return 1; }
    command -v curl >/dev/null || { printf '%s\n' 'curl is required.' >&2; return 1; }
    python3 - <<'PY'
import json
import os
import re
import shlex
import stat
import subprocess
import sys
import tempfile
from pathlib import Path

BEGIN = b'<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-TOOLS -->'
END = b'<!-- END MATT-POCOCK-GRILLING-QUESTION-TOOLS -->'
TOKEN = b'MATT-POCOCK-GRILLING-QUESTION-TOOLS'
POLICY_BEGIN = '<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-POLICY v2 -->'
POLICY_END = '<!-- END MATT-POCOCK-GRILLING-QUESTION-POLICY v2 -->'
BASE = 'https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056'
HOOK_NAME = 'grilling-question-hook.py'
HOOK_HEADER = '#!/usr/bin/env python3\n# MATT-POCOCK-GRILLING-QUESTION-HOOK v2\n'
HOOK_EVENTS = (('PostToolUse', 'Skill'), ('UserPromptSubmit', None))


def download(url):
    result = subprocess.run(['curl', '-fsSL', '--proto', '=https', '--proto-redir', '=https',
                             '--connect-timeout', '15', '--max-time', '60', url],
                            stdout=subprocess.PIPE, check=True)
    return result.stdout


def is_managed_hook(hook):
    return (isinstance(hook, dict) and isinstance(hook.get('command'), str)
            and HOOK_NAME in hook['command'])


def merge_settings(path, old, command):
    # Returns new settings bytes. Only hook entries running HOOK_NAME are managed;
    # every other key, event, and hook is kept in its original order.
    try:
        settings = json.loads(old.decode('utf-8')) if old.strip() else {}
    except (UnicodeDecodeError, ValueError) as error:
        raise ValueError('Claude settings are not valid JSON: %s (%s)' % (path, error))
    if not isinstance(settings, dict):
        raise ValueError('Claude settings must be a JSON object: %s' % path)
    original = json.loads(json.dumps(settings))
    hooks = settings.get('hooks', {})
    if not isinstance(hooks, dict):
        raise ValueError('Claude settings "hooks" must be an object: %s' % path)
    for event in list(hooks):
        groups = hooks[event]
        if not isinstance(groups, list) or not all(
                isinstance(g, dict) and isinstance(g.get('hooks', []), list) for g in groups):
            raise ValueError('Unexpected Claude hooks layout for %s in %s. Fix it manually.' % (event, path))
        kept, removed = [], False
        for group in groups:
            entries = group.get('hooks', [])
            others = [h for h in entries if not is_managed_hook(h)]
            if len(others) != len(entries):
                removed = True
                if not others:
                    continue
                group = dict(group, hooks=others)
            kept.append(group)
        if removed and not kept:
            del hooks[event]
        else:
            hooks[event] = kept
    if command:
        for event, matcher in HOOK_EVENTS:
            group = {'matcher': matcher} if matcher else {}
            group['hooks'] = [{'type': 'command', 'command': command, 'timeout': 10}]
            hooks.setdefault(event, []).append(group)
    if hooks or 'hooks' in settings:
        settings['hooks'] = hooks
    if settings == original:
        return old
    return (json.dumps(settings, indent=2, ensure_ascii=False) + '\n').encode('utf-8')


def resolve_target(path):
    # Resolve existing components strictly, but allow new files/directories.
    target = Path(path.anchor)
    for part in path.parts[1:]:
        target = target / part
        if target.is_symlink():
            try:
                target = target.resolve(strict=True)
            except (OSError, RuntimeError) as error:
                raise ValueError('Dangling or unresolvable symlink: %s' % target) from error
    return target


def check_path(path):
    for part in [path] + list(path.parents):
        if part.is_symlink():
            raise ValueError('Path changed to a symlink during installation: %s' % part)
        if part.exists() and not (part.is_file() if part == path else part.is_dir()):
            raise ValueError('Not a regular file/directory: %s' % part)
    parent = path.parent
    while not parent.exists():
        parent = parent.parent
    if not os.access(parent, os.W_OK | os.X_OK):
        raise ValueError('Directory is not writable/searchable: %s' % parent)
    if path.exists() and not os.access(path, os.R_OK | os.W_OK):
        raise ValueError('Instruction file is not readable/writable: %s' % path)


def install():
    if sys.version_info < (3, 8):
        raise ValueError('Python 3.8+ is required.')
    revision = os.environ.get('QUESTION_TOOLS_REVISION') or 'HEAD'
    if revision != 'HEAD' and not re.fullmatch(r'[0-9a-fA-F]{40}', revision):
        raise ValueError('QUESTION_TOOLS_REVISION must be HEAD or a full 40-character commit hash.')
    hook_setting = os.environ.get('QUESTION_TOOLS_CLAUDE_HOOK') or '1'
    if hook_setting not in ('0', '1'):
        raise ValueError('QUESTION_TOOLS_CLAUDE_HOOK must be 1 (install) or 0 (skip and remove).')
    url = BASE + '/raw/' + revision + '/grilling-question-tools.md'
    policy_bytes = download(url)
    policy = policy_bytes.decode('utf-8')
    lines = policy.splitlines()
    if (not lines or lines[0] != POLICY_BEGIN or lines[-1] != POLICY_END
            or policy.count(POLICY_BEGIN) != 1 or policy.count(POLICY_END) != 1
            or len(policy) > 32768 or '\x00' in policy
            or TOKEN.decode() in policy
            or not policy.startswith(POLICY_BEGIN + '\n# AskUserQuestion for Matt Pocock Grilling Flows\n')
            or not all('\n%d. ' % i in policy for i in range(1, 8))):
        raise ValueError('Downloaded policy is incomplete or invalid; no instruction files changed.')
    hook_bytes = None
    if hook_setting == '1':
        hook_url = BASE + '/raw/' + revision + '/' + HOOK_NAME
        hook_bytes = download(hook_url)
        hook_source = hook_bytes.decode('utf-8')
        if (not hook_source.startswith(HOOK_HEADER) or len(hook_source) > 32768
                or '\x00' in hook_source or TOKEN.decode() in hook_source
                or not hook_source.rstrip().endswith('sys.exit(0)')):
            raise ValueError('Downloaded Claude hook is incomplete or invalid; no instruction files changed.')
        try:
            compile(hook_source, HOOK_NAME, 'exec')
        except SyntaxError as error:
            raise ValueError('Downloaded Claude hook does not compile: %s' % error)
    home = os.environ.get('HOME')
    if not home or not os.path.isabs(home):
        raise ValueError('HOME must be an absolute path.')
    claude_dir = os.environ.get('CLAUDE_CONFIG_DIR') or home + '/.claude'
    if (not os.path.isabs(claude_dir) or '..' in Path(claude_dir).parts
            or any(c in claude_dir for c in '\r\n`')):
        raise ValueError('CLAUDE_CONFIG_DIR must be an absolute path without .. components.')
    policy_dir = Path(claude_dir) / 'grilling-question-tools'
    policy_path = resolve_target(policy_dir / 'grilling-question-tools.md')
    if any(c in str(policy_path) for c in '\r\n`'):
        raise ValueError('Policy path cannot contain newlines or backticks.')
    block = (BEGIN + b'\n' + (
        'Before running a Matt Pocock grilling flow (grilling, grill-me, grill-with-docs,\n'
        'loop-me, or a skill that calls grilling), read `%s`.\n'
        'Ask its questions with AskUserQuestion as that policy describes;\n'
        'preserve all other workflow requirements.\n' % policy_path
    ).encode('utf-8') + END)
    paths = [resolve_target(Path(claude_dir) / 'CLAUDE.md')]
    hook_path = resolve_target(policy_dir / HOOK_NAME)
    settings_path = resolve_target(Path(claude_dir) / 'settings.json')
    # A hook command is run by a shell, so quote both paths.
    command = ('python3 %s %s' % (shlex.quote(str(hook_path)), shlex.quote(str(policy_path)))
               if hook_bytes is not None else None)
    managed = [policy_path, hook_path, settings_path]
    everything = managed + paths
    existing = [p for p in everything if p.exists()]
    if len(set(everything)) != len(everything) or any(
            os.path.samefile(a, b) for i, a in enumerate(existing) for b in existing[i + 1:]):
        raise ValueError('Policy, hook, settings, and CLAUDE.md must be separate files.')
    changes = []
    # Preflight every destination before creating directories, backups, or files.
    for path in managed + paths:
        check_path(path)
        exists = path.exists()
        old = path.read_bytes() if exists else b''
        if path == policy_path:
            new = policy_bytes
        elif path == hook_path:
            new = hook_bytes if hook_bytes is not None else old
        elif path == settings_path:
            new = merge_settings(path, old, command)
        elif TOKEN in old:
            if old.count(TOKEN) != 2 or old.count(BEGIN) != 1 or old.count(END) != 1:
                raise ValueError('Malformed/duplicate managed markers: %s. Repair them manually before rerunning.' % path)
            start, end = old.index(BEGIN), old.index(END)
            after = end + len(END)
            if (end < start or (start and old[start-1:start] != b'\n')
                    or old[end-1:end] != b'\n'
                    or (old[after:] and not old[after:].startswith((b'\n', b'\r\n')))
                    or not old[start+len(BEGIN):].startswith((b'\n', b'\r\n'))):
                raise ValueError('Malformed managed marker boundaries: %s' % path)
            new = old[:start] + block + old[after:]
        else:
            separator = (b'\n' if old.endswith(b'\n') else b'\n\n') if old else b''
            new = old + separator + block + b'\n'
        if new != old:
            changes.append((path, exists, old, new))
        elif exists:
            print('Unchanged: %s' % path)

    staged = []
    try:
        for path, exists, old, new in changes:
            check_path(path)
            if path.exists() != exists or (exists and path.read_bytes() != old):
                raise ValueError('File changed during installation: %s. Retry with other writers stopped.' % path)
            path.parent.mkdir(parents=True, exist_ok=True)
            mode = stat.S_IMODE(path.stat().st_mode) if exists else 0o600
            fd, temporary = tempfile.mkstemp(prefix='.' + path.name + '.question-tools-', dir=str(path.parent))
            staged.append((path, temporary))
            with os.fdopen(fd, 'wb') as stream:
                stream.write(new)
                stream.flush()
                os.fsync(stream.fileno())
                os.fchmod(stream.fileno(), mode)
            if exists:
                fd, backup = tempfile.mkstemp(prefix=path.name + '.question-tools-backup-', dir=str(path.parent))
                with os.fdopen(fd, 'wb') as stream:
                    stream.write(old)
                    stream.flush()
                    os.fsync(stream.fileno())
                print('Backup: %s' % backup)
        for path, temporary in staged:
            os.replace(temporary, path)
            print('Updated: %s' % path)
    finally:
        for _, temporary in staged:
            if os.path.exists(temporary):
                os.unlink(temporary)
    print('Policy source: %s' % url)
    if command:
        print('Claude Code hook: %s (PostToolUse on Skill, UserPromptSubmit)' % settings_path)
    else:
        print('Claude Code hook: skipped (QUESTION_TOOLS_CLAUDE_HOOK=0); managed entries removed if present.')
    print('Start a fresh Claude Code session; /hooks lists the registered hooks.')


try:
    install()
except (OSError, ValueError, subprocess.SubprocessError) as error:
    print('Installation failed: %s' % error, file=sys.stderr)
    sys.exit(1)
PY
}

main "$@"
