<?php

declare(strict_types=1);

/**
 * Hand-curated OpenAPI 3.0 spec for IRDB.
 *
 * Run: `composer openapi:build` (or `php api/openapi.php > api/public/openapi.yaml`).
 * The committed `api/public/openapi.yaml` MUST match this file's output —
 * the doc-accuracy CI guard (`scripts/check-doc-endpoints.sh`) treats the
 * generated spec as the source of truth for which endpoints exist.
 *
 * Why hand-curated and not annotation-based: keeps the API surface in one
 * place where it can be reviewed in PR diffs, avoids pulling
 * zircote/swagger-php into prod deps, and the static array fits in <500
 * lines with room for descriptions. Trade-off: drift between code and
 * spec is possible — the integration tests assert response shapes
 * separately, and CI runs `redocly lint` against the YAML to catch
 * malformed entries.
 *
 * Internal endpoints (`/internal/jobs/*`) are deliberately omitted per
 * SPEC §M13.1; they're scheduler-only and not part of the public
 * contract.
 */

require_once __DIR__ . '/vendor/autoload.php';

$errorEnvelope = [
    'type' => 'object',
    'required' => ['error'],
    'properties' => [
        'error' => ['type' => 'string', 'example' => 'unauthorized'],
        'details' => [
            'type' => 'object',
            'description' => 'Field-level errors for `validation_failed`.',
            'additionalProperties' => ['type' => 'string'],
        ],
    ],
];

$pageMeta = [
    'page' => ['type' => 'integer', 'minimum' => 1, 'example' => 1],
    'page_size' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200, 'example' => 50],
    'total' => ['type' => 'integer', 'minimum' => 0, 'example' => 1284],
];

$components = [
    'securitySchemes' => [
        'BearerAuth' => [
            'type' => 'http',
            'scheme' => 'bearer',
            'description' => "Token in the form `irdb_<kind>_<32 base32 chars>`.\n"
                . 'See `doc/auth-flows.md` for the four token kinds.',
        ],
    ],
    'parameters' => [
        'Page' => [
            'name' => 'page',
            'in' => 'query',
            'schema' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
        ],
        'PageSize' => [
            'name' => 'page_size',
            'in' => 'query',
            'schema' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200, 'default' => 50],
        ],
        'ActingUserId' => [
            'name' => 'X-Acting-User-Id',
            'in' => 'header',
            'description' => "Required when authenticating with a `service` token.\n"
                . 'The api applies RBAC for the named user. Ignored on other token kinds.',
            'schema' => ['type' => 'integer'],
        ],
    ],
    'schemas' => [
        'Error' => $errorEnvelope,
        'ReportRequest' => [
            'type' => 'object',
            'required' => ['ip', 'category'],
            'properties' => [
                'ip' => ['type' => 'string', 'example' => '203.0.113.42'],
                'category' => ['type' => 'string', 'example' => 'brute_force'],
                'metadata' => [
                    'type' => 'object',
                    'description' => 'Free-form per-report data, max 4 KB after json_encode.',
                    'additionalProperties' => true,
                ],
            ],
        ],
        'ReportResponse' => [
            'type' => 'object',
            'properties' => [
                'report_id' => ['type' => 'integer', 'example' => 12345],
                'ip' => ['type' => 'string', 'example' => '203.0.113.42'],
                'received_at' => ['type' => 'string', 'format' => 'date-time'],
            ],
        ],
        'BlocklistEntry' => [
            'type' => 'object',
            'properties' => [
                'ip_or_cidr' => ['type' => 'string', 'example' => '203.0.113.42'],
                'categories' => ['type' => 'array', 'items' => ['type' => 'string']],
                'score' => ['type' => 'number', 'format' => 'float', 'example' => 1.42],
                'reason' => ['type' => 'string', 'enum' => ['scored', 'manual'], 'example' => 'scored'],
            ],
        ],
        'BlocklistJson' => [
            'type' => 'object',
            'properties' => [
                'count' => ['type' => 'integer', 'example' => 42],
                'generated_at' => ['type' => 'string', 'format' => 'date-time'],
                'policy' => ['type' => 'string', 'example' => 'moderate'],
                'entries' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/BlocklistEntry']],
            ],
        ],
        'Token' => [
            'type' => 'object',
            'properties' => [
                'id' => ['type' => 'integer'],
                'kind' => ['type' => 'string', 'enum' => ['reporter', 'consumer', 'admin']],
                'prefix' => ['type' => 'string', 'example' => 'irdb_adm'],
                'reporter_id' => ['type' => 'integer', 'nullable' => true],
                'consumer_id' => ['type' => 'integer', 'nullable' => true],
                'role' => ['type' => 'string', 'nullable' => true, 'enum' => ['viewer', 'operator', 'admin', null]],
                'user_id' => ['type' => 'integer', 'nullable' => true, 'description' => 'For admin-kind tokens issued via the API, the id of the issuing admin. Reporter, consumer, service, and console-issued admin tokens carry null. When non-null, the token is rejected (401) at auth time if the issuer is later disabled or demoted below the token role. (SEC_REVIEW F16.)'],
                'user_label' => ['type' => 'string', 'nullable' => true, 'description' => 'Denormalised display name (or email) of the issuing user; null when user_id is null or the user has since been deleted. List responses only.'],
                'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
                'revoked_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
                'last_used_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
            ],
        ],
        'TokenCreated' => [
            'allOf' => [
                ['$ref' => '#/components/schemas/Token'],
                [
                    'type' => 'object',
                    'properties' => [
                        'raw_token' => [
                            'type' => 'string',
                            'description' => 'Returned ONCE on creation — copy it now, never displayed again.',
                            'example' => 'irdb_adm_AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD',
                        ],
                    ],
                ],
            ],
        ],
        'Reporter' => [
            'type' => 'object',
            'properties' => [
                'id' => ['type' => 'integer'],
                'name' => ['type' => 'string', 'example' => 'web-prod-01'],
                'description' => ['type' => 'string', 'nullable' => true],
                'trust_weight' => ['type' => 'number', 'format' => 'float', 'minimum' => 0.0, 'maximum' => 2.0],
                'is_active' => ['type' => 'boolean'],
            ],
        ],
        'Consumer' => [
            'type' => 'object',
            'properties' => [
                'id' => ['type' => 'integer'],
                'name' => ['type' => 'string', 'example' => 'edge-fw-01'],
                'description' => ['type' => 'string', 'nullable' => true],
                'policy_id' => ['type' => 'integer'],
                'is_active' => ['type' => 'boolean'],
                'last_pulled_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
            ],
        ],
        'Category' => [
            'type' => 'object',
            'properties' => [
                'id' => ['type' => 'integer'],
                'slug' => ['type' => 'string', 'example' => 'brute_force'],
                'name' => ['type' => 'string'],
                'description' => ['type' => 'string', 'nullable' => true],
                'decay_function' => ['type' => 'string', 'enum' => ['linear', 'exponential']],
                'decay_param' => ['type' => 'number', 'format' => 'float'],
                'is_active' => ['type' => 'boolean'],
            ],
        ],
        'Policy' => [
            'type' => 'object',
            'properties' => [
                'id' => ['type' => 'integer'],
                'name' => ['type' => 'string', 'example' => 'moderate'],
                'description' => ['type' => 'string', 'nullable' => true],
                'include_manual_blocks' => ['type' => 'boolean'],
                'thresholds' => [
                    'type' => 'object',
                    'description' => '`{category_slug: threshold}`',
                    'additionalProperties' => ['type' => 'number', 'format' => 'float'],
                ],
            ],
        ],
        'ManualBlock' => [
            'type' => 'object',
            'properties' => [
                'id' => ['type' => 'integer'],
                'kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']],
                'ip' => ['type' => 'string', 'nullable' => true],
                'cidr' => ['type' => 'string', 'nullable' => true],
                'reason' => ['type' => 'string', 'nullable' => true],
                'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
                'created_at' => ['type' => 'string', 'format' => 'date-time'],
            ],
        ],
        'AllowlistEntry' => [
            'type' => 'object',
            'properties' => [
                'id' => ['type' => 'integer'],
                'kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']],
                'ip' => ['type' => 'string', 'nullable' => true],
                'cidr' => ['type' => 'string', 'nullable' => true],
                'reason' => ['type' => 'string', 'nullable' => true],
                'created_at' => ['type' => 'string', 'format' => 'date-time'],
            ],
        ],
        'IpDetail' => [
            'type' => 'object',
            'properties' => [
                'ip' => ['type' => 'string'],
                'is_ipv4' => ['type' => 'boolean'],
                'scores' => [
                    'type' => 'array',
                    'items' => [
                        'type' => 'object',
                        'properties' => [
                            'category' => ['type' => 'string'],
                            'score' => ['type' => 'number', 'format' => 'float'],
                            'last_report_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
                            'report_count_30d' => ['type' => 'integer'],
                        ],
                    ],
                ],
                'enrichment' => [
                    'type' => 'object',
                    'properties' => [
                        'country_code' => ['type' => 'string', 'nullable' => true],
                        'asn' => ['type' => 'integer', 'nullable' => true],
                        'as_org' => ['type' => 'string', 'nullable' => true],
                        'enriched_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
                    ],
                ],
                'status' => ['type' => 'string', 'enum' => ['scored', 'manually_blocked', 'allowlisted', 'clean']],
                'manual_block' => ['type' => 'object', 'nullable' => true],
                'allowlist' => ['type' => 'object', 'nullable' => true],
                'history' => ['type' => 'array', 'items' => ['type' => 'object']],
                'has_more' => ['type' => 'boolean'],
            ],
        ],
        'AuditEntry' => [
            'type' => 'object',
            'properties' => [
                'id' => ['type' => 'integer'],
                'occurred_at' => ['type' => 'string', 'format' => 'date-time'],
                'actor_kind' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']],
                'actor_id' => ['type' => 'string', 'nullable' => true],
                'action' => ['type' => 'string', 'example' => 'manual_block.created'],
                'entity_type' => ['type' => 'string', 'nullable' => true],
                'entity_id' => ['type' => 'string', 'nullable' => true],
                'entity_label' => ['type' => 'string', 'nullable' => true, 'description' => 'Human-readable identifier for the target (name, slug, IP, CIDR, prefix). Frozen at write time.'],
                'details' => ['type' => 'object', 'nullable' => true, 'additionalProperties' => true, 'description' => 'For update events, contains a `changes` map of `{field: {from, to}}` for every modified field.'],
                'source_ip' => ['type' => 'string', 'nullable' => true],
            ],
        ],
        'JobStatus' => [
            'type' => 'object',
            'properties' => [
                'name' => ['type' => 'string'],
                'default_interval_seconds' => ['type' => 'integer'],
                'max_runtime_seconds' => ['type' => 'integer'],
                'overdue' => ['type' => 'boolean'],
                'last_run' => [
                    'type' => 'object',
                    'nullable' => true,
                    'properties' => [
                        'id' => ['type' => 'integer'],
                        'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
                        'items_processed' => ['type' => 'integer'],
                        'triggered_by' => ['type' => 'string', 'enum' => ['schedule', 'manual', 'api']],
                        'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
                        'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
                        'error_message' => ['type' => 'string', 'nullable' => true],
                    ],
                ],
            ],
        ],
        'JobOutcome' => [
            'type' => 'object',
            'properties' => [
                'job' => ['type' => 'string'],
                'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
                'items_processed' => ['type' => 'integer'],
                'duration_ms' => ['type' => 'integer'],
                'run_id' => ['type' => 'integer', 'nullable' => true],
                'error' => ['type' => 'string', 'nullable' => true],
            ],
        ],
        'User' => [
            'type' => 'object',
            'properties' => [
                'id' => ['type' => 'integer'],
                'email' => ['type' => 'string', 'nullable' => true],
                'display_name' => ['type' => 'string'],
                'role' => ['type' => 'string', 'enum' => ['viewer', 'operator', 'admin']],
                'source' => ['type' => 'string', 'enum' => ['oidc', 'local', 'admin-token']],
                'is_local' => ['type' => 'boolean'],
            ],
        ],
        'Pagination' => [
            'type' => 'object',
            'properties' => $pageMeta,
        ],
    ],
];

$paths = [
    // ---------- Public ----------
    '/api/v1/report' => [
        'post' => [
            'tags' => ['Public'],
            'summary' => 'Submit an abuse report',
            'description' => "Token kind: `reporter`. Rate limit: 60 req/s per token (configurable).\n"
                . 'Returns `202 Accepted` on success.',
            'security' => [['BearerAuth' => []]],
            'requestBody' => [
                'required' => true,
                'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportRequest']]],
            ],
            'responses' => [
                '202' => [
                    'description' => 'Report accepted',
                    'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportResponse']]],
                ],
                '400' => ['description' => 'Validation failed', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
                '401' => ['description' => 'Bad / wrong-kind token', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
                '429' => [
                    'description' => 'Rate limited',
                    'headers' => ['Retry-After' => ['schema' => ['type' => 'integer'], 'description' => 'Seconds to wait before retrying.']],
                    'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]],
                ],
            ],
        ],
    ],
    '/api/v1/blocklist' => [
        'get' => [
            'tags' => ['Public'],
            'summary' => 'Pull a tailored blocklist',
            'description' => "Token kind: `consumer`. The consumer's bound policy decides which IPs/CIDRs land in the output.\n"
                . "Cached internally for 30 s per consumer. Honour `If-None-Match` to skip retransfer.\n"
                . "`?format=json` returns structured rows; default is `text/plain`, one entry per line.",
            'security' => [['BearerAuth' => []]],
            'parameters' => [
                ['name' => 'format', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['text', 'json'], 'default' => 'text']],
                ['name' => 'If-None-Match', 'in' => 'header', 'schema' => ['type' => 'string']],
            ],
            'responses' => [
                '200' => [
                    'description' => 'Current blocklist',
                    'headers' => [
                        'ETag' => ['schema' => ['type' => 'string']],
                        'X-Blocklist-Generated-At' => ['schema' => ['type' => 'string', 'format' => 'date-time']],
                        'X-Blocklist-Entries' => ['schema' => ['type' => 'integer']],
                        'X-Blocklist-Policy' => ['schema' => ['type' => 'string']],
                    ],
                    'content' => [
                        'text/plain' => ['schema' => ['type' => 'string', 'example' => "203.0.113.42\n198.51.100.0/24\n"]],
                        'application/json' => ['schema' => ['$ref' => '#/components/schemas/BlocklistJson']],
                    ],
                ],
                '304' => ['description' => 'Not modified — body matches `If-None-Match`'],
                '401' => ['description' => 'Bad / wrong-kind token'],
            ],
        ],
    ],

    // ---------- Admin: identity ----------
    '/api/v1/admin/me' => [
        'get' => [
            'tags' => ['Admin'],
            'summary' => 'Current acting identity',
            'security' => [['BearerAuth' => []]],
            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
            'responses' => [
                '200' => ['description' => 'Identity info', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]],
            ],
        ],
    ],

    // ---------- Admin: IPs ----------
    '/api/v1/admin/ips' => [
        'get' => [
            'tags' => ['Admin'],
            'summary' => 'Search IPs',
            'security' => [['BearerAuth' => []]],
            'parameters' => [
                ['$ref' => '#/components/parameters/ActingUserId'],
                ['name' => 'q', 'in' => 'query', 'schema' => ['type' => 'string']],
                ['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']],
                ['name' => 'min_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
                ['name' => 'max_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
                ['name' => 'country', 'in' => 'query', 'schema' => ['type' => 'string']],
                ['name' => 'asn', 'in' => 'query', 'schema' => ['type' => 'integer']],
                ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['scored', 'manual', 'allowlisted', 'clean']]],
                ['$ref' => '#/components/parameters/Page'],
                ['$ref' => '#/components/parameters/PageSize'],
            ],
            'responses' => ['200' => ['description' => 'Page of IPs', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['type' => 'object']]])]]]]],
        ],
    ],
    '/api/v1/admin/ips/countries' => [
        'get' => [
            'tags' => ['Admin'],
            'summary' => 'Country code dropdown source',
            'security' => [['BearerAuth' => []]],
            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
            'responses' => ['200' => ['description' => '`[{code, count}]`', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['items' => ['type' => 'array', 'items' => ['type' => 'object', 'properties' => ['code' => ['type' => 'string'], 'count' => ['type' => 'integer']]]]]]]]]],
        ],
    ],
    '/api/v1/admin/ips/{ip}' => [
        'get' => [
            'tags' => ['Admin'],
            'summary' => 'IP detail',
            'security' => [['BearerAuth' => []]],
            'parameters' => [
                ['$ref' => '#/components/parameters/ActingUserId'],
                ['name' => 'ip', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']],
            ],
            'responses' => [
                '200' => ['description' => 'IP detail', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/IpDetail']]]],
                '404' => ['description' => 'Invalid IP / not found'],
            ],
        ],
    ],

    // ---------- Admin: stats / dashboard ----------
    '/api/v1/admin/stats/dashboard' => [
        'get' => [
            'tags' => ['Admin'],
            'summary' => 'Dashboard stats (30s cached)',
            'security' => [['BearerAuth' => []]],
            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
            'responses' => ['200' => ['description' => 'Dashboard payload', 'content' => ['application/json' => ['schema' => ['type' => 'object']]]]],
        ],
    ],

    // ---------- Admin: manual blocks / allowlist ----------
    '/api/v1/admin/manual-blocks' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'List manual blocks', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
        'post' => [
            'tags' => ['Admin'], 'summary' => 'Create manual block',
            'description' => 'Operator+ role required. `kind=ip` requires `ip`; `kind=subnet` requires `cidr`.',
            'security' => [['BearerAuth' => []]],
            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
            'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']], 'ip' => ['type' => 'string'], 'cidr' => ['type' => 'string'], 'reason' => ['type' => 'string'], 'expires_at' => ['type' => 'string', 'format' => 'date-time']]]]]],
            'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ManualBlock']]]]],
        ],
    ],
    '/api/v1/admin/manual-blocks/{id}' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'Show manual block', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'manual block', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ManualBlock']]]]]],
        'delete' => ['tags' => ['Admin'], 'summary' => 'Delete manual block', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
    ],
    '/api/v1/admin/allowlist' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'List allowlist', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
        'post' => ['tags' => ['Admin'], 'summary' => 'Create allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']], 'ip' => ['type' => 'string'], 'cidr' => ['type' => 'string'], 'reason' => ['type' => 'string']]]]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/AllowlistEntry']]]]]],
    ],
    '/api/v1/admin/allowlist/{id}' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'Show allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'allowlist entry', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/AllowlistEntry']]]]]],
        'delete' => ['tags' => ['Admin'], 'summary' => 'Delete allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
    ],

    // ---------- Admin: reporters / consumers / tokens ----------
    '/api/v1/admin/reporters' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'List reporters', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
        'post' => ['tags' => ['Admin'], 'summary' => 'Create reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
    ],
    '/api/v1/admin/reporters/{id}' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'Show reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'reporter', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
        'patch' => ['tags' => ['Admin'], 'summary' => 'Update reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]], 'responses' => ['200' => ['description' => 'Updated', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
        'delete' => ['tags' => ['Admin'], 'summary' => 'Soft-delete reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Has reports — flagged inactive instead.']]],
    ],
    '/api/v1/admin/consumers' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'List consumers', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
        'post' => ['tags' => ['Admin'], 'summary' => 'Create consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]]]],
    ],
    '/api/v1/admin/consumers/{id}' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'Show consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'consumer', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]]]],
        'patch' => ['tags' => ['Admin'], 'summary' => 'Update consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]], 'responses' => ['200' => ['description' => 'Updated']]],
        'delete' => ['tags' => ['Admin'], 'summary' => 'Soft-delete consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
    ],
    '/api/v1/admin/tokens' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'List tokens', 'description' => 'Service tokens are filtered out unconditionally.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
        'post' => ['tags' => ['Admin'], 'summary' => 'Create token', 'description' => 'Returns the raw token ONCE in `raw_token`. Service tokens are not creatable here.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['reporter', 'consumer', 'admin']], 'reporter_id' => ['type' => 'integer'], 'consumer_id' => ['type' => 'integer'], 'role' => ['type' => 'string', 'enum' => ['viewer', 'operator', 'admin']], 'expires_at' => ['type' => 'string', 'format' => 'date-time']]]]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/TokenCreated']]]]]],
    ],
    '/api/v1/admin/tokens/{id}' => [
        'delete' => ['tags' => ['Admin'], 'summary' => 'Revoke token', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Revoked']]],
    ],

    // ---------- Admin: categories / policies ----------
    '/api/v1/admin/categories' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'List categories', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
        'post' => ['tags' => ['Admin'], 'summary' => 'Create category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]]]],
    ],
    '/api/v1/admin/categories/{id}' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'Show category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'category', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]]]],
        'patch' => ['tags' => ['Admin'], 'summary' => 'Update category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]], 'responses' => ['200' => ['description' => 'Updated']]],
        'delete' => ['tags' => ['Admin'], 'summary' => 'Hard-delete category (refused if in use)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Category in use; soft-delete via PATCH `is_active=false`.']]],
    ],
    '/api/v1/admin/policies' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'List policies', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
        'post' => ['tags' => ['Admin'], 'summary' => 'Create policy', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]]]],
    ],
    '/api/v1/admin/policies/{id}' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'Show policy', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'policy', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]]]],
        'patch' => ['tags' => ['Admin'], 'summary' => 'Update policy (replaces thresholds wholesale when present)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]], 'responses' => ['200' => ['description' => 'Updated']]],
        'delete' => ['tags' => ['Admin'], 'summary' => 'Delete policy (refused if used by consumers)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Policy in use']]],
    ],
    '/api/v1/admin/policies/{id}/preview' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'Preview policy (count + sample)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'Preview']]],
    ],

    // ---------- Admin: audit / jobs / config ----------
    '/api/v1/admin/audit-log' => [
        'get' => [
            'tags' => ['Admin'], 'summary' => 'Filtered audit log',
            'security' => [['BearerAuth' => []]],
            'parameters' => [
                ['$ref' => '#/components/parameters/ActingUserId'],
                ['name' => 'actor_kind', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']]],
                ['name' => 'actor_id', 'in' => 'query', 'schema' => ['type' => 'integer']],
                ['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string']],
                ['name' => 'entity_type', 'in' => 'query', 'schema' => ['type' => 'string']],
                ['name' => 'entity_id', 'in' => 'query', 'schema' => ['type' => 'string']],
                ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
                ['name' => 'to', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
                ['$ref' => '#/components/parameters/Page'],
                ['$ref' => '#/components/parameters/PageSize'],
            ],
            'responses' => ['200' => ['description' => 'Audit page', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/AuditEntry']]])]]]]],
        ],
    ],
    '/api/v1/admin/jobs/status' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'Jobs status (Viewer)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'Jobs status', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['now' => ['type' => 'string', 'format' => 'date-time'], 'jobs' => ['type' => 'object', 'additionalProperties' => ['$ref' => '#/components/schemas/JobStatus']]]]]]]]],
    ],
    '/api/v1/admin/jobs/trigger/{name}' => [
        'post' => [
            'tags' => ['Admin'], 'summary' => 'Manually trigger a job (Admin)',
            'description' => 'Whitelisted params: `full`, `max_rows`, `reenrich`. Other body fields are dropped.',
            'security' => [['BearerAuth' => []]],
            'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'name', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']]],
            'requestBody' => ['content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['full' => ['type' => 'boolean'], 'max_rows' => ['type' => 'integer'], 'reenrich' => ['type' => 'boolean']]]]]],
            'responses' => [
                '200' => ['description' => 'Job ran', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/JobOutcome']]]],
                '404' => ['description' => 'Unknown job'],
                '409' => ['description' => 'Lock held; status=`skipped_locked`'],
                '412' => ['description' => 'refresh-geoip without credential'],
            ],
        ],
    ],
    '/api/v1/admin/config' => [
        'get' => ['tags' => ['Admin'], 'summary' => 'Effective config (secrets masked)', 'description' => 'Admin only.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'Config sections', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['sections' => ['type' => 'object', 'additionalProperties' => ['type' => 'object']]]]]]]]],
    ],
    '/api/v1/admin/maintenance/purge' => [
        'post' => [
            'tags' => ['Admin'],
            'summary' => 'Wipe operational data (Admin)',
            'description' => "Deletes reports, scores, enrichment, manual blocks, allowlist, audit log, job history, reporters, consumers, policies, and non-service tokens. Preserves users, OIDC role mappings, abuse categories, and the `service`-kind token.\n\nRequires `confirm: \"PURGE\"` in the body — any other value returns 400.",
            'security' => [['BearerAuth' => []]],
            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
            'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'required' => ['confirm'], 'properties' => ['confirm' => ['type' => 'string', 'enum' => ['PURGE']]]]]]],
            'responses' => [
                '200' => ['description' => 'Purge succeeded', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['status' => ['type' => 'string', 'example' => 'purged'], 'deleted' => ['type' => 'object', 'additionalProperties' => ['type' => 'integer']]]]]]],
                '400' => ['description' => 'Missing or wrong `confirm` value'],
            ],
        ],
    ],
    '/api/v1/admin/maintenance/seed-demo' => [
        'post' => [
            'tags' => ['Admin'],
            'summary' => 'Load demo dataset (Admin)',
            'description' => "Populates reporters, consumers, IPs, reports, manual blocks, allowlist, and synthetic GeoIP for demos and screenshots. Triggers a full score recompute on completion. Idempotent: returns 409 if demo data is already present.\n\nRequires `confirm: \"SEED\"` in the body — any other value returns 400.",
            'security' => [['BearerAuth' => []]],
            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
            'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'required' => ['confirm'], 'properties' => ['confirm' => ['type' => 'string', 'enum' => ['SEED']]]]]]],
            'responses' => [
                '200' => ['description' => 'Demo data inserted', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['status' => ['type' => 'string', 'example' => 'seeded'], 'summary' => ['type' => 'object', 'additionalProperties' => ['type' => 'integer']], 'recompute' => ['$ref' => '#/components/schemas/JobOutcome']]]]]],
                '400' => ['description' => 'Missing or wrong `confirm` value'],
                '409' => ['description' => 'Demo data already present'],
                '412' => ['description' => 'No categories configured'],
            ],
        ],
    ],

    // ---------- Auth (UI BFF only) ----------
    '/api/v1/auth/users/upsert-oidc' => [
        'post' => [
            'tags' => ['Auth'],
            'summary' => 'Upsert an OIDC-authenticated user',
            'description' => "**UI BFF only.** Service-token-required, no impersonation header.\n"
                . 'Resolves the user record + role (via `oidc_role_mappings`) for a freshly-validated ID token.',
            'x-internal' => true,
            'security' => [['BearerAuth' => []]],
            'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['subject' => ['type' => 'string'], 'email' => ['type' => 'string'], 'display_name' => ['type' => 'string'], 'groups' => ['type' => 'array', 'items' => ['type' => 'string']]]]]]],
            'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
        ],
    ],
    '/api/v1/auth/users/upsert-local' => [
        'post' => [
            'tags' => ['Auth'],
            'summary' => 'Upsert the local-admin user',
            'description' => '**UI BFF only.** Called after the UI validates the local-admin password.',
            'x-internal' => true,
            'security' => [['BearerAuth' => []]],
            'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['username' => ['type' => 'string']]]]]],
            'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
        ],
    ],
    '/api/v1/auth/users/{id}' => [
        'get' => [
            'tags' => ['Auth'],
            'summary' => 'Refetch a user record',
            'description' => "**UI BFF only.** Used to refresh role / display_name during a session.\n\nEvery call (200 and 404) emits a `user.fetched` audit row so iterative enumeration of user ids leaves a per-id trail (SEC_REVIEW F17). The route group is also rate-limited per service-token id (SEC_REVIEW F14).",
            'x-internal' => true,
            'security' => [['BearerAuth' => []]],
            'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]],
            'responses' => [
                '200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]],
                '404' => ['description' => 'No user with this id', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['error' => ['type' => 'string', 'example' => 'not_found']]]]]],
            ],
        ],
    ],
];

$spec = [
    'openapi' => '3.0.3',
    'info' => [
        'title' => 'IRDB — IP Reputation Database',
        'version' => '1.0.0',
        'description' => "Self-hosted IP reputation service: ingest abuse reports, distribute tailored block lists.\n\n"
            . "## Versioning\n\n"
            . "Single major version `v1`. Changes within `v1` are additive only — new endpoints, new optional fields, new optional query params. Breaking changes ship as `v2`.\n\n"
            . "## Endpoint groups\n\n"
            . "- **Public**: machine clients (reporters, consumers).\n"
            . "- **Admin**: UI BFF + admin-kind tokens. RBAC enforced server-side.\n"
            . "- **Auth**: UI BFF only — bridges browser auth to user records. Marked `x-internal: true`.\n"
            . "- **Internal jobs**: not in this spec. Scheduler-only, network-restricted.\n\n"
            . "## Authentication\n\n"
            . "Bearer token in the `Authorization` header. Four token kinds: `reporter`, `consumer`, `admin`, `service`. See `doc/auth-flows.md`.\n\n"
            . "## Errors\n\n"
            . "Uniform envelope: `{\"error\":\"<code>\",\"details\":{...}}`. Validation errors include `details`. Authentication failures return `401` `unauthorized`. Authorization failures return `403`.\n\n"
            . "## Rate limiting\n\n"
            . "Public endpoints: 60 req/s/token (configurable). On exhaustion: `429` with `Retry-After: 1`.",
        'license' => ['name' => 'TBD'],
    ],
    'servers' => [
        ['url' => 'http://localhost:8081', 'description' => 'Default compose deployment'],
        ['url' => 'https://reputation-api.example.com', 'description' => 'Production (replace hostname)'],
    ],
    'tags' => [
        ['name' => 'Public', 'description' => 'Machine clients: reporters and blocklist consumers.'],
        ['name' => 'Admin', 'description' => 'UI BFF + admin-kind tokens.'],
        ['name' => 'Auth', 'description' => 'UI BFF only. Service token required, no impersonation.'],
    ],
    'security' => [['BearerAuth' => []]],
    'paths' => $paths,
    'components' => $components,
];

echo dumpYaml($spec);

/**
 * Minimal YAML dumper that handles the subset we use here:
 *  - assoc arrays become mappings
 *  - lists become block sequences
 *  - strings get quoted only when needed (newlines, leading specials, special chars)
 *  - bools / ints / floats / null serialise plainly
 *
 * @param mixed $value
 */
function dumpYaml(mixed $value, int $indent = 0): string
{
    if (!is_array($value)) {
        return scalarYaml($value) . "\n";
    }
    if ($value === []) {
        // Caller decides whether `[]` is "empty list" or "empty object".
        // We always emit empty list — callers that need an empty object
        // pass a sentinel (we don't have any in this spec).
        return "[]\n";
    }
    $isList = array_is_list($value);
    $out = '';
    $pad = str_repeat('  ', $indent);
    foreach ($value as $k => $v) {
        if ($isList) {
            if (is_array($v) && $v !== []) {
                // Render the child at indent 0, then prefix the first line
                // with "- " and subsequent lines with two-space continuation.
                $rawSub = rtrim(dumpYaml($v, 0), "\n");
                $childPad = str_repeat('  ', $indent + 1);
                $lines = explode("\n", $rawSub);
                $first = true;
                foreach ($lines as $line) {
                    if ($first) {
                        $out .= $pad . '- ' . $line . "\n";
                        $first = false;
                    } else {
                        $out .= $childPad . $line . "\n";
                    }
                }
            } else {
                $out .= $pad . '- ' . scalarYaml($v, $indent) . "\n";
            }
        } else {
            $key = (string) $k;
            // Quote keys that contain special chars
            if (preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', $key) !== 1 || $key === 'on' || $key === 'no' || $key === 'yes' || $key === 'off') {
                $key = "'" . str_replace("'", "''", $key) . "'";
            }
            if (is_array($v)) {
                if ($v === []) {
                    $out .= $pad . $key . ": []\n";
                } else {
                    $out .= $pad . $key . ":\n" . dumpYaml($v, $indent + 1);
                }
            } else {
                $out .= $pad . $key . ': ' . scalarYaml($v, $indent) . "\n";
            }
        }
    }

    return $out;
}

function scalarYaml(mixed $value, int $parentIndent = 0): string
{
    if ($value === null) {
        return 'null';
    }
    if (is_bool($value)) {
        return $value ? 'true' : 'false';
    }
    if (is_int($value) || is_float($value)) {
        return (string) $value;
    }
    $s = (string) $value;
    if ($s === '') {
        return "''";
    }
    // Multi-line: literal block scalar. Content must be indented one level
    // *deeper* than the key — i.e. `parentIndent + 1`. The caller already
    // emitted "<key>: <this>" so we return "|\n<indented lines>".
    if (str_contains($s, "\n")) {
        $lines = explode("\n", rtrim($s, "\n"));
        $contentPad = str_repeat('  ', $parentIndent + 1);
        $block = '|';
        foreach ($lines as $line) {
            $block .= "\n" . ($line === '' ? '' : $contentPad . $line);
        }

        return $block;
    }
    // Quote if it could be misread as bool/null/number, contains special chars, or starts with sigils.
    $needsQuote = preg_match('/^(true|false|null|yes|no|on|off|~)$/i', $s) === 1
        || preg_match('/^[\\-?:,\\[\\]\\{\\}#&*!|>\'"%@`]/', $s) === 1
        || preg_match('/^[+\\-]?\\d/', $s) === 1
        || str_contains($s, ': ')
        || str_contains($s, ' #')
        || str_starts_with($s, ' ')
        || str_ends_with($s, ' ');
    if ($needsQuote) {
        return "'" . str_replace("'", "''", $s) . "'";
    }

    return $s;
}
