<?php
declare(strict_types=1);

/*
 * facturas.compuindex.store — backend mínimo
 *   GET  /           -> login o app (según sesión)
 *   POST /login      -> inicia sesión
 *   POST /logout     -> cierra sesión
 *   GET  /api/data   -> devuelve el JSON completo
 *   POST /api/save   -> guarda una clave: db-prod | db-cli | factura-count
 *
 * Config (usuario + hash): /etc/facturas/config.php
 * Datos:                   /var/lib/facturas/data.json (+ backups/ diarios)
 */

const CONFIG_FILE = '/etc/facturas/config.php';
const DATA_DIR    = '/var/lib/facturas';
const DATA_FILE   = DATA_DIR . '/data.json';
const APP_FILE    = __DIR__ . '/../app.html';
const SESSION_TTL = 7 * 24 * 3600;      // 7 días
const MAX_BODY    = 2 * 1024 * 1024;    // 2 MB
const MAX_FAILS   = 5;                  // intentos fallidos por IP...
const FAIL_WINDOW = 900;                // ...en 15 minutos
const KEEP_BACKUPS = 30;
const DEFAULTS    = ['db-prod' => [], 'db-cli' => [], 'factura-count' => 0];

ini_set('display_errors', '0');
date_default_timezone_set('America/Argentina/Buenos_Aires');

/* ---------- helpers ---------- */

function send_json(int $code, mixed $payload): never
{
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: no-store');
    echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

function require_csrf(): void
{
    $expected = (string)($_SESSION['csrf'] ?? '');
    $sent     = (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
    if ($expected === '' || !hash_equals($expected, $sent)) {
        send_json(403, ['error' => 'csrf']);
    }
}

function read_data(): array
{
    $raw = @file_get_contents(DATA_FILE);
    if ($raw === false) {
        return DEFAULTS;
    }
    $d = json_decode($raw, true);
    if (!is_array($d)) {
        // No pisar un archivo corrupto con valores vacíos: mejor fallar.
        throw new RuntimeException('data.json ilegible');
    }
    return array_merge(DEFAULTS, $d);
}

function snapshot_daily(): void
{
    if (!is_file(DATA_FILE)) {
        return;
    }
    $dir = DATA_DIR . '/backups';
    if (!is_dir($dir)) {
        mkdir($dir, 0750, true);
    }
    $dst = $dir . '/data-' . date('Ymd') . '.json';
    if (!is_file($dst)) {
        copy(DATA_FILE, $dst);
    }
    $files = glob($dir . '/data-*.json') ?: [];
    sort($files);
    foreach (array_slice($files, 0, max(0, count($files) - KEEP_BACKUPS)) as $old) {
        @unlink($old);
    }
}

/** Lee, modifica y escribe data.json de forma atómica y con lock. */
function update_data(callable $fn): void
{
    $lock = fopen(DATA_DIR . '/.lock', 'c');
    if (!$lock || !flock($lock, LOCK_EX)) {
        throw new RuntimeException('no se pudo tomar el lock');
    }
    try {
        $data = $fn(read_data());
        $json = json_encode(
            $data,
            JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
        );
        snapshot_daily();
        $tmp = tempnam(DATA_DIR, 'tmp');
        if ($tmp === false || file_put_contents($tmp, $json) === false) {
            throw new RuntimeException('no se pudo escribir');
        }
        chmod($tmp, 0640);
        rename($tmp, DATA_FILE);
    } finally {
        flock($lock, LOCK_UN);
        fclose($lock);
    }
}

/* ---------- límite de intentos de login (por IP) ---------- */

function attempts(callable $fn): mixed
{
    $f = fopen(DATA_DIR . '/.attempts.json', 'c+');
    if (!$f || !flock($f, LOCK_EX)) {
        return null;
    }
    $a   = json_decode((string)stream_get_contents($f), true);
    $a   = is_array($a) ? $a : [];
    $now = time();
    foreach ($a as $ip => $times) {
        $a[$ip] = array_values(array_filter((array)$times, fn($t) => $t > $now - FAIL_WINDOW));
        if (!$a[$ip]) {
            unset($a[$ip]);
        }
    }
    [$a, $ret] = $fn($a, $now);
    ftruncate($f, 0);
    rewind($f);
    fwrite($f, json_encode($a));
    flock($f, LOCK_UN);
    fclose($f);
    return $ret;
}

function login_blocked(string $ip): bool
{
    return (bool)attempts(fn($a, $n) => [$a, count($a[$ip] ?? []) >= MAX_FAILS]);
}

function login_fail(string $ip): void
{
    attempts(function ($a, $n) use ($ip) {
        $a[$ip][] = $n;
        return [$a, null];
    });
}

function login_clear(string $ip): void
{
    attempts(function ($a, $n) use ($ip) {
        unset($a[$ip]);
        return [$a, null];
    });
}

/* ---------- vistas ---------- */

function render_login(string $error = '', int $code = 200): never
{
    http_response_code($code);
    header('Content-Type: text/html; charset=utf-8');
    header('Cache-Control: no-store');
    $err = $error !== '' ? '<p class="err" role="alert">' . htmlspecialchars($error, ENT_QUOTES) . '</p>' : '';
    echo <<<HTML
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Ingresar — Presupuestos</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;600&display=swap" rel="stylesheet">
<style>
  :root { --bg:#0f0f0f; --surface:#1a1a1a; --border:#2e2e2e; --accent:#e8c84a; --text:#f0f0f0; --muted:#888; --danger:#e05a5a; }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: 'IBM Plex Sans', system-ui, sans-serif; background: var(--bg); color: var(--text);
         min-height: 100vh; display: grid; place-items: center; padding: 20px; }
  main { width: 100%; max-width: 340px; background: var(--surface); border: 1px solid var(--border);
         border-radius: 4px; padding: 24px; }
  h1 { font-size: 1.1rem; font-weight: 600; margin-bottom: 18px; }
  label { display: block; font-size: 0.75rem; color: var(--muted); margin-top: 12px; }
  input { width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text);
          padding: 9px 10px; border-radius: 3px; font: inherit; font-size: 0.9rem; margin-top: 4px; }
  input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
  button { width: 100%; margin-top: 20px; padding: 10px; background: var(--accent); color: #111; border: none;
           border-radius: 3px; font: inherit; font-weight: 600; cursor: pointer; }
  button:hover { filter: brightness(1.08); }
  .err { margin-top: 14px; font-size: 0.8rem; color: var(--danger); }
</style>
</head>
<body>
<main>
  <h1>Generador de presupuestos</h1>
  <form method="post" action="/login">
    <label for="user">Usuario</label>
    <input id="user" name="user" type="text" autocomplete="username" autocapitalize="none" required autofocus>
    <label for="password">Contraseña</label>
    <input id="password" name="password" type="password" autocomplete="current-password" required>
    <button type="submit">Ingresar</button>
    {$err}
  </form>
</main>
</body>
</html>
HTML;
    exit;
}

/* ---------- arranque ---------- */

$cfg = @include CONFIG_FILE;
if (!is_array($cfg) || empty($cfg['user']) || empty($cfg['password_hash'])) {
    error_log('facturas: falta o es inválido ' . CONFIG_FILE);
    http_response_code(500);
    exit('Error de configuración');
}

$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
session_name('facturas_sid');
session_set_cookie_params([
    'lifetime' => SESSION_TTL,
    'path'     => '/',
    'secure'   => $https,
    'httponly' => true,
    'samesite' => 'Strict',
]);
session_start();

$authed = !empty($_SESSION['auth']) && (time() - (int)($_SESSION['login_at'] ?? 0)) < SESSION_TTL;

$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path   = rtrim((string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/') ?: '/';

try {
    switch (true) {
        case $path === '/' && $method === 'GET':
            if (!$authed) {
                render_login();
            }
            $html = file_get_contents(APP_FILE);
            if ($html === false) {
                throw new RuntimeException('falta app.html');
            }
            header('Content-Type: text/html; charset=utf-8');
            header('Cache-Control: no-store');
            echo str_replace('{{CSRF}}', (string)$_SESSION['csrf'], $html);
            exit;

        case $path === '/login' && $method === 'POST':
            $ip = (string)($_SERVER['REMOTE_ADDR'] ?? '?');
            if (login_blocked($ip)) {
                render_login('Demasiados intentos. Probá de nuevo en unos minutos.', 429);
            }
            $u = (string)($_POST['user'] ?? '');
            $p = substr((string)($_POST['password'] ?? ''), 0, 1024);
            // Se evalúan los dos siempre, para no filtrar cuál falló por tiempo de respuesta.
            $okUser = hash_equals((string)$cfg['user'], $u);
            $okPass = password_verify($p, (string)$cfg['password_hash']);
            if ($okUser && $okPass) {
                login_clear($ip);
                session_regenerate_id(true);
                $_SESSION = [
                    'auth'     => true,
                    'login_at' => time(),
                    'csrf'     => bin2hex(random_bytes(32)),
                ];
                header('Location: /', true, 303);
                exit;
            }
            login_fail($ip);
            usleep(500_000);
            render_login('Usuario o contraseña incorrectos.', 401);

        case $path === '/logout' && $method === 'POST':
            if (!$authed) {
                send_json(200, ['ok' => true]);
            }
            require_csrf();
            $_SESSION = [];
            if (ini_get('session.use_cookies')) {
                $c = session_get_cookie_params();
                setcookie(session_name(), '', [
                    'expires'  => time() - 3600,
                    'path'     => $c['path'],
                    'secure'   => $c['secure'],
                    'httponly' => true,
                    'samesite' => 'Strict',
                ]);
            }
            session_destroy();
            send_json(200, ['ok' => true]);

        case $path === '/api/data' && $method === 'GET':
            if (!$authed) {
                send_json(401, ['error' => 'no autenticado']);
            }
            send_json(200, read_data());

        case $path === '/api/save' && $method === 'POST':
            if (!$authed) {
                send_json(401, ['error' => 'no autenticado']);
            }
            require_csrf();
            $body = (string)file_get_contents('php://input', false, null, 0, MAX_BODY + 1);
            if (strlen($body) > MAX_BODY) {
                send_json(413, ['error' => 'demasiado grande']);
            }
            $req = json_decode($body, true);
            if (!is_array($req) || !isset($req['key']) || !array_key_exists('value', $req)) {
                send_json(400, ['error' => 'pedido inválido']);
            }
            $key = (string)$req['key'];
            $val = $req['value'];

            if ($key === 'db-prod' || $key === 'db-cli') {
                $ok = is_array($val) && array_is_list($val)
                    && count(array_filter($val, fn($x) => !is_array($x))) === 0;
            } elseif ($key === 'factura-count') {
                $ok = is_int($val) && $val >= 0 && $val < 100_000_000;
            } else {
                send_json(400, ['error' => 'clave desconocida']);
            }
            if (!$ok) {
                send_json(422, ['error' => 'valor inválido para ' . $key]);
            }

            update_data(function (array $data) use ($key, $val) {
                $data[$key] = $val;
                return $data;
            });
            send_json(200, ['ok' => true]);

        default:
            send_json(404, ['error' => 'no encontrado']);
    }
} catch (Throwable $e) {
    error_log('facturas: ' . $e->getMessage());
    send_json(500, ['error' => 'error del servidor']);
}
