<?php
/**
 * api.php — REST API for updates.json
 * ─────────────────────────────────────────────────────────────────────────────
 * Drop alongside index.php. The JSON store and API key path are configured
 * in the CONFIG block below.
 *
 * Authentication
 *   Pass the key in the X-API-Key header or the ?api_key= query parameter.
 *
 * Endpoints
 *   GET    api.php                       List posts  (?limit=N &offset=N &tag=foo)
 *   GET    api.php?id=<id>               Get one post
 *   POST   api.php                       Create post  (JSON body)
 *   PUT    api.php?id=<id>               Update post  (JSON body, partial)
 *   DELETE api.php?id=<id>              Delete post
 *   POST   api.php?action=reorder        Reorder  (JSON body: {"ids":["003","001",…]})
 *   POST   api.php?action=bulk_delete    Bulk delete (JSON body: {"ids":["001","002"]})
 *
 * Post schema
 *   id         string   auto-generated if omitted on create
 *   text       string   required
 *   date       string   ISO-8601; defaults to now() on create
 *   tags       string[] optional
 *   link       string   optional URL
 *   link_label string   optional
 */

// ══════════════════════════════════════════════════════════════════════════════
// CONFIG — edit this block only
// ══════════════════════════════════════════════════════════════════════════════

// Path to updates.json (relative to this file or absolute)
define('API_JSON_PATH', __DIR__ . '/index_config/updates.json');

// Path to api_key.txt — one key per line; lines starting with # are ignored.
// Generate a key: php -r "echo bin2hex(random_bytes(32));"
define('API_KEY_FILE',  __DIR__ . '/index_config/api_keys.txt');

// Allowed CORS origin. '*' opens to all; set to 'https://tyclifford.com' to lock.
define('API_CORS_ORIGIN', 'https:/tyclifford.com');

// Maximum posts returned by a single list request (hard ceiling).
define('API_MAX_LIMIT', 100);

// ══════════════════════════════════════════════════════════════════════════════
// BOOTSTRAP
// ══════════════════════════════════════════════════════════════════════════════

header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
if (API_CORS_ORIGIN !== '') {
    header('Access-Control-Allow-Origin: '  . API_CORS_ORIGIN);
    header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
    header('Access-Control-Allow-Headers: X-API-Key, Content-Type');
}

// Handle CORS preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function api_ok(mixed $data, int $code = 200): never {
    http_response_code($code);
    echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
    exit;
}

function api_err(string $message, int $code = 400): never {
    http_response_code($code);
    echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_UNICODE);
    exit;
}

// ── Auth ──────────────────────────────────────────────────────────────────────

function api_auth(): void {
    if (!file_exists(API_KEY_FILE)) {
        api_err('API key file not configured. Create index_config/api_keys.txt with at least one key.', 503);
    }

    $supplied = trim(
        $_SERVER['HTTP_X_API_KEY']
        ?? $_SERVER['HTTP_X_Api_Key']
        ?? ($_GET['api_key'] ?? '')
    );

    if ($supplied === '') {
        api_err('Missing API key. Supply X-API-Key header or ?api_key= parameter.', 401);
    }

    $valid_keys = array_filter(
        array_map('trim', file(API_KEY_FILE)),
        fn($line) => $line !== '' && !str_starts_with($line, '#')
    );

    foreach ($valid_keys as $key) {
        if (hash_equals($key, $supplied)) return; // constant-time compare
    }

    api_err('Invalid API key.', 403);
}

// ── JSON store ────────────────────────────────────────────────────────────────

function load_posts(): array {
    if (!file_exists(API_JSON_PATH)) return [];
    $raw = file_get_contents(API_JSON_PATH);
    $data = json_decode($raw, true);
    return is_array($data) ? $data : [];
}

function save_posts(array $posts): void {
    $dir = dirname(API_JSON_PATH);
    if (!is_dir($dir)) {
        if (!mkdir($dir, 0755, true)) {
            api_err('Cannot create data directory.', 500);
        }
    }

    $json = json_encode(
        array_values($posts),           // reindex
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
    );

    // Atomic write with exclusive lock
    $tmp = API_JSON_PATH . '.tmp.' . getmypid();
    if (file_put_contents($tmp, $json, LOCK_EX) === false) {
        api_err('Failed to write data file.', 500);
    }
    if (!rename($tmp, API_JSON_PATH)) {
        @unlink($tmp);
        api_err('Failed to replace data file.', 500);
    }
}

function find_post(array $posts, string $id): int|false {
    foreach ($posts as $i => $p) {
        if (($p['id'] ?? '') === $id) return $i;
    }
    return false;
}

function generate_id(array $posts): string {
    $existing = array_column($posts, 'id');
    do {
        $id = strtolower(bin2hex(random_bytes(4)));
    } while (in_array($id, $existing, true));
    return $id;
}

function validate_post(array $input, bool $require_text = true): array {
    $errors = [];

    if ($require_text && trim($input['text'] ?? '') === '') {
        $errors[] = '"text" is required and cannot be empty.';
    }
    if (isset($input['text']) && mb_strlen($input['text']) > 4000) {
        $errors[] = '"text" exceeds 4000 characters.';
    }
    if (isset($input['date'])) {
        $dt = DateTime::createFromFormat(DateTime::ATOM, $input['date'])
           ?: DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $input['date'])
           ?: DateTime::createFromFormat('Y-m-d', $input['date']);
        if ($dt === false) {
            $errors[] = '"date" must be ISO-8601 (e.g. 2025-08-14T21:30:00Z).';
        }
    }
    if (isset($input['tags'])) {
        if (!is_array($input['tags'])) {
            $errors[] = '"tags" must be an array of strings.';
        } else {
            foreach ($input['tags'] as $t) {
                if (!is_string($t) || mb_strlen($t) > 50) {
                    $errors[] = 'Each tag must be a string ≤ 50 characters.';
                    break;
                }
            }
        }
    }
    if (isset($input['link']) && $input['link'] !== '') {
        if (!filter_var($input['link'], FILTER_VALIDATE_URL)) {
            $errors[] = '"link" must be a valid URL or empty string.';
        }
    }

    return $errors;
}

function sanitise_post(array $input): array {
    $out = [];
    if (isset($input['text']))       $out['text']       = trim((string)$input['text']);
    if (isset($input['date']))       $out['date']       = trim((string)$input['date']);
    if (isset($input['tags']))       $out['tags']       = array_values(array_map('trim', (array)$input['tags']));
    if (array_key_exists('link', $input))       $out['link']       = trim((string)$input['link']);
    if (array_key_exists('link_label', $input)) $out['link_label'] = trim((string)$input['link_label']);
    return $out;
}

// ── Request body ──────────────────────────────────────────────────────────────

function read_body(): array {
    $raw = file_get_contents('php://input');
    if ($raw === '' || $raw === false) return [];
    $data = json_decode($raw, true);
    if (!is_array($data)) api_err('Request body must be valid JSON.', 400);
    return $data;
}

// ══════════════════════════════════════════════════════════════════════════════
// ROUTING
// ══════════════════════════════════════════════════════════════════════════════

api_auth();

$method = $_SERVER['REQUEST_METHOD'];
$id     = trim($_GET['id']     ?? '');
$action = trim($_GET['action'] ?? '');

// ── POST ?action=reorder ──────────────────────────────────────────────────────
if ($method === 'POST' && $action === 'reorder') {
    $body = read_body();
    if (!isset($body['ids']) || !is_array($body['ids'])) {
        api_err('"ids" array is required.');
    }

    $posts   = load_posts();
    $indexed = [];
    foreach ($posts as $p) $indexed[$p['id']] = $p;

    $reordered = [];
    foreach ($body['ids'] as $eid) {
        if (!is_string($eid) || !isset($indexed[$eid])) {
            api_err("Unknown id \"$eid\" in reorder list.");
        }
        $reordered[] = $indexed[$eid];
        unset($indexed[$eid]);
    }
    // Append any posts not mentioned in the ids list at the end
    foreach ($indexed as $p) $reordered[] = $p;

    save_posts($reordered);
    api_ok(['reordered' => count($reordered)]);
}

// ── POST ?action=bulk_delete ──────────────────────────────────────────────────
if ($method === 'POST' && $action === 'bulk_delete') {
    $body = read_body();
    if (!isset($body['ids']) || !is_array($body['ids'])) {
        api_err('"ids" array is required.');
    }

    $posts   = load_posts();
    $del_set = array_flip($body['ids']);
    $kept    = array_values(array_filter($posts, fn($p) => !isset($del_set[$p['id'] ?? ''])));
    $deleted = count($posts) - count($kept);

    save_posts($kept);
    api_ok(['deleted' => $deleted]);
}

// ── GET — list or single ──────────────────────────────────────────────────────
if ($method === 'GET') {
    $posts = load_posts();

    if ($id !== '') {
        $idx = find_post($posts, $id);
        if ($idx === false) api_err("Post \"$id\" not found.", 404);
        api_ok($posts[$idx]);
    }

    // Filtering
    $tag = trim($_GET['tag'] ?? '');
    if ($tag !== '') {
        $posts = array_values(array_filter($posts, fn($p) => in_array($tag, (array)($p['tags'] ?? []), true)));
    }

    // Pagination
    $total  = count($posts);
    $limit  = min(max(1, (int)($_GET['limit']  ?? $total)), API_MAX_LIMIT);
    $offset = max(0, (int)($_GET['offset'] ?? 0));
    $page   = array_slice($posts, $offset, $limit);

    api_ok([
        'total'  => $total,
        'limit'  => $limit,
        'offset' => $offset,
        'items'  => $page,
    ]);
}

// ── POST — create ─────────────────────────────────────────────────────────────
if ($method === 'POST' && $action === '') {
    $body   = read_body();
    $errors = validate_post($body, require_text: true);
    if ($errors) api_err(implode(' ', $errors));

    $posts = load_posts();

    // Allow caller to supply id; generate one if missing or already taken
    $new_id = trim((string)($body['id'] ?? ''));
    if ($new_id === '' || find_post($posts, $new_id) !== false) {
        $new_id = generate_id($posts);
    }

    $safe = sanitise_post($body);
    $post = [
        'id'         => $new_id,
        'text'       => $safe['text'],
        'date'       => $safe['date'] ?? (new DateTime('now', new DateTimeZone('UTC')))->format(DateTime::ATOM),
        'tags'       => $safe['tags']       ?? [],
        'link'       => $safe['link']       ?? '',
        'link_label' => $safe['link_label'] ?? '',
    ];

    array_unshift($posts, $post);   // newest first
    save_posts($posts);
    api_ok($post, 201);
}

// ── PUT — update ──────────────────────────────────────────────────────────────
if ($method === 'PUT') {
    if ($id === '') api_err('?id= is required for PUT.');

    $posts = load_posts();
    $idx   = find_post($posts, $id);
    if ($idx === false) api_err("Post \"$id\" not found.", 404);

    $body   = read_body();
    $errors = validate_post($body, require_text: false);
    if ($errors) api_err(implode(' ', $errors));

    $safe            = sanitise_post($body);
    $posts[$idx]     = array_merge($posts[$idx], $safe);
    $posts[$idx]['id'] = $id;   // id is immutable

    save_posts($posts);
    api_ok($posts[$idx]);
}

// ── DELETE — single ───────────────────────────────────────────────────────────
if ($method === 'DELETE') {
    if ($id === '') api_err('?id= is required for DELETE.');

    $posts = load_posts();
    $idx   = find_post($posts, $id);
    if ($idx === false) api_err("Post \"$id\" not found.", 404);

    $deleted = $posts[$idx];
    array_splice($posts, $idx, 1);
    save_posts($posts);
    api_ok($deleted);
}

api_err('Method not allowed.', 405);
