<?php
/* ============================================================
   Front controller / router for plan.vibemarketing.no
   ============================================================ */
// Long-lived crew session: ~30 days, rolling (refreshed on every visit). Safe because
// auth_user() re-checks the admin row (AND is_active) on every request — so the session
// only keeps working while the crew member still exists.
$__sessLife = 60 * 60 * 24 * 30;   // 30 days
$__sessSecure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
             || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');   // behind Plesk/nginx proxy

// Only START a session when one is actually needed — otherwise every passive offer view, the
// tracking beacon and API call would create a throwaway session file + cookie. We start it for:
// a returning visitor (existing session cookie), the crew/portal areas, or the interactive
// public POSTs that set a flash / 2FA (sign, sendcode, comment, guest).
$__seg = explode('/', trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/'));
$__needSession = isset($_COOKIE[session_name()])
    || in_array($__seg[0] ?? '', ['crew', 'portal'], true)
    || (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && in_array($__seg[1] ?? '', ['sign', 'sendcode', 'comment', 'guest'], true));
if ($__needSession) {
    ini_set('session.gc_maxlifetime', (string)$__sessLife);
    // Keep session files in OUR OWN private dir (outside the web root). The default dir is shared
    // and is swept by the system's `sessionclean` cron using the GLOBAL gc_maxlifetime (~24 min) —
    // which deleted our files early and logged crew back out despite the 30-day cookie.
    $__sessDir = dirname(__DIR__) . '/3399cc-plan/sessions';
    if (!is_dir($__sessDir)) @mkdir($__sessDir, 0700, true);
    if (is_dir($__sessDir) && is_writable($__sessDir)) {
        ini_set('session.save_path', $__sessDir);
        ini_set('session.gc_probability', '1');
        ini_set('session.gc_divisor', '100');
    }
    session_set_cookie_params([
        'lifetime' => $__sessLife, 'path' => '/',
        'secure' => $__sessSecure, 'httponly' => true, 'samesite' => 'Lax',
    ]);
    session_start();
    // Roll the cookie forward so an active crew member stays signed in (30 days from last visit).
    if (!empty($_SESSION['admin_id'])) {
        setcookie(session_name(), session_id(), [
            'expires' => time() + $__sessLife, 'path' => '/',
            'secure' => $__sessSecure, 'httponly' => true, 'samesite' => 'Lax',
        ]);
    }
}
if (!isset($_SESSION)) $_SESSION = [];   // no session this request → reads (auth_user/flash) stay safe & empty
define('DOCROOT', __DIR__);   // web root (holds uploads/, assets/) — used to locate files for PDF embedding
// All app code + config live OUTSIDE the web root, in ../3399cc-plan/ (obscured, site-unique name).
require_once dirname(__DIR__) . '/3399cc-plan/bootstrap.php';

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if (BASE_PATH && strpos($path, BASE_PATH) === 0) $path = substr($path, strlen(BASE_PATH));
$path   = trim($path, '/');
$seg    = $path === '' ? [] : explode('/', $path);
$method = $_SERVER['REQUEST_METHOD'];

try {
    // Owner shortcut: configured IPs hitting / or /portal go straight to /crew (all domains).
    // Bypass for testing as a client: open /portal?kunde once — the flag lasts the session;
    // an already-logged-in portal session is never redirected either.
    if (in_array($_SERVER['REMOTE_ADDR'] ?? '', (array) CREW_REDIRECT_IPS, true)
        && in_array($seg[0] ?? '', ['', 'portal'], true)) {
        if (isset($_GET['kunde'])) $_SESSION['portal_bypass'] = true;
        if (empty($_SESSION['portal_bypass']) && empty($_SESSION['portal_phone'])) redirect('crew');
    }
    if (($seg[0] ?? '') === 'api') {
        // FlowMap: /api/sitemaps/* delegates to the flow module (gated; flow_route_api exits)
        if (($seg[1] ?? '') === 'sitemaps' && FLOWMAP_ENABLED) {
            require_once dirname(__DIR__) . '/3399cc-plan/flow/lib/routes.php';
            flow_route_api(array_values(array_slice($seg, 2)), $method);
        }
        route_api(array_values($seg), $method);   // JSON REST API (Bearer auth; no session/CSRF)
    } elseif (($seg[0] ?? '') === 'update') {
        // Auto-update endpoints (key-authenticated, no session/CSRF; 404 while UPDATE_KEY is unset)
        require_once dirname(__DIR__) . '/3399cc-plan/lib/update.php';
        $ua = $seg[1] ?? '';
        if ($ua === 'manifest') upd_serve_manifest();                        // exits
        if ($ua === 'package')  upd_serve_package();                         // exits
        if ($ua === 'status')   upd_serve_status();                          // exits
        if ($ua === 'run' && $method === 'POST') upd_serve_run();            // exits
        http_response_code(404); echo 'Not found';
    } elseif (($seg[0] ?? '') === 'crew') {
        route_crew(array_values(array_slice($seg, 1)), $method);
    } elseif (($seg[0] ?? '') === 'portal') {
        route_portal(array_values(array_slice($seg, 1)), $method);
    } elseif (isset($seg[0]) && $seg[0] !== '') {
        // FlowMap: a root segment may be a flyt share-hash (gated; returns false → offer routing)
        $flowHandled = false;
        if (FLOWMAP_ENABLED) {
            require_once dirname(__DIR__) . '/3399cc-plan/flow/lib/routes.php';
            $flowHandled = flow_route_hash($seg[0], $seg[1] ?? '', $method);
        }
        // Public offer lives at the domain root: /{hash}, /{hash}/sign, /{hash}/comment, /{hash}/pdf
        if (!$flowHandled) route_offer($seg[0], $seg[1] ?? '', $method);
    } else {
        // Root: send visitors to the client portal, not the admin login (keep /crew unadvertised).
        redirect('portal');
    }
} catch (Throwable $ex) {
    http_response_code(500);
    echo '<h1>Det oppstod en feil</h1>';
    if (ini_get('display_errors')) echo '<pre>' . e($ex->getMessage()) . "\n" . e($ex->getTraceAsString()) . '</pre>';
}

/* PUBLIC OFFER + all tilbud-specific logic → 3399cc-plan/offer/lib/routes.php (module split) */

/* ============================================================
   CREW / ADMIN
   ============================================================ */
function route_crew(array $a, string $method): void {
    $r = $a[0] ?? '';

    // First-run: force creation of the first admin. On a BRAND-NEW install the DB is empty
    // (admins table missing) — build the whole schema via the migrations runner, then continue
    // to setup. Makes a new site self-installing: files + config + empty DB → open /crew.
    try { $adminsCount = admins_count(); }
    catch (PDOException $e) {
        require_once dirname(__DIR__) . '/3399cc-plan/lib/update.php';
        upd_migrate();
        $adminsCount = admins_count();
    }
    if ($adminsCount === 0 && $r !== 'setup') redirect('crew/setup');

    switch ($r) {
        case '':
            require_admin();
            if (!admin_can('tilbud')) redirect(FLOWMAP_ENABLED && admin_can('flow') ? 'crew/flow' : 'crew/clients');   // flyt-only crew lands on Flyt
            crew_start(); return;

        case 'tilbud':
            require_admin(); if (!admin_can('tilbud')) redirect('crew'); crew_dashboard(); return;

        case 'maler':
            require_admin(); if (!admin_can('tilbud')) redirect('crew'); crew_templates(); return;

        case 'flow':   // FlowMap module (Sitemaps) — all logic lives in 3399cc-plan/flow/
            if (!FLOWMAP_ENABLED) redirect('crew');
            require_admin();
            if (!admin_can('flow')) redirect('crew');
            require_once dirname(__DIR__) . '/3399cc-plan/flow/lib/routes.php';
            flow_route_crew(array_values(array_slice($a, 1)), $method); return;

        case 'sections':
            require_admin(); if (!admin_can('tilbud')) redirect('crew');
            $sid = $a[1] ?? '';
            if ($sid === 'save' && $method === 'POST') { csrf_check(); json_out(section_template_save()); }
            if ($sid === 'new') {
                $newType = in_array($a[2] ?? 'section', ['section','budget','table'], true) ? $a[2] : 'section';
                $e = null;
                if ($method === 'POST') { csrf_check(); $e = crew_section_save(null); if (!$e) redirect('crew/maler'); }
                crew_section_form(null, $e, $newType); return;
            }
            if (ctype_digit((string)$sid)) {
                $sub = $a[2] ?? '';
                if ($sub === 'delete' && $method === 'POST') { csrf_check(); section_template_delete((int)$sid); return; }
                if ($sub === 'rename' && $method === 'POST') { csrf_check(); section_template_rename((int)$sid); return; }
                if ($sub === 'default' && $method === 'POST') { csrf_check(); section_template_set_default((int)$sid); return; }
                $e = null;
                if ($method === 'POST') { csrf_check(); $e = crew_section_save((int)$sid); if (!$e) redirect('crew/maler'); }
                crew_section_form((int)$sid, $e); return;
            }
            redirect('crew/maler'); return;

        case 'clients':
            require_admin();
            $id = $a[1] ?? '';
            if ($id === 'new' && $method === 'POST') { csrf_check(); crew_client_create(); return; }
            if (ctype_digit((string)$id)) {
                if (($a[2] ?? '') === 'delete' && $method === 'POST') { csrf_check(); crew_client_delete((int)$id); return; }
                $e = null;
                if ($method === 'POST') { csrf_check(); $e = crew_client_save((int)$id); }
                crew_client_form((int)$id, $e); return;
            }
            crew_clients_view(); return;

        case 'guests':
            require_admin();
            $gid = $a[1] ?? ''; $sub = $a[2] ?? '';
            if (ctype_digit((string)$gid) && $method === 'POST' && in_array($sub, ['revoke','restore'], true)) {
                csrf_check(); crew_guest_set_revoked((int)$gid, $sub === 'revoke'); return;
            }
            redirect('crew/clients'); return;

        case 'login':
            if (auth_user()) redirect('crew');
            $err = null;
            if ($method === 'POST') {
                csrf_check();
                if (!empty($_SESSION['pending_login']) && isset($_POST['code'])) {
                    // Step 2: verify the SMS code
                    $pid = (int) $_SESSION['pending_login'];
                    $ok = twofa_check('login:' . $pid, $_POST['code'] ?? '');
                    if ($ok) {
                        unset($_SESSION['pending_login']); login_admin($pid); redirect('crew');
                    }
                    $err = 'Feil eller utløpt kode.';
                } elseif (isset($_POST['cancel'])) {
                    unset($_SESSION['pending_login']);
                } else {
                    // Step 1: phone → send one-time SMS code (passwordless)
                    if (!sms_enabled()) {
                        $err = 'SMS-innlogging er ikke konfigurert ennå.';
                    } else {
                        $a = find_admin_by_phone($_POST['telefon'] ?? '');
                        if ($a && twofa_start('login:' . $a['id'], $a['phone'])) {
                            $_SESSION['pending_login'] = (int) $a['id'];
                        } elseif ($a) {
                            $err = 'Kunne ikke sende SMS-kode. Prøv igjen.';
                        } else {
                            $err = 'Fant ingen aktiv bruker med dette nummeret.';
                        }
                    }
                }
            }
            $pending = !empty($_SESSION['pending_login']);
            $maskedPhone = $pending ? mask_phone(twofa_phone('login:' . $_SESSION['pending_login'])) : '';
            render_admin('Logg inn', view_path('login.php'), compact('err', 'pending', 'maskedPhone')); return;

        case 'logout':
            logout(); redirect('crew/login'); return;

        case 'setup':
            if (admins_count() > 0) redirect('crew/login');
            $err = null;
            if ($method === 'POST') { csrf_check(); $err = crew_setup_save(); if (!$err) redirect('crew'); }
            render_admin('Opprett første bruker', view_path('setup.php'), ['err' => $err]); return;

        case 'offers':
            require_admin(); if (!admin_can('tilbud')) redirect('crew');
            $id = $a[1] ?? '';
            if ($id === 'new') {
                $err = null;
                if ($method === 'POST') { csrf_check(); $err = crew_offer_save(null); }
                crew_offer_form(null, $err); return;
            }
            if ($id === 'import') {
                if ($method === 'POST') { csrf_check(); crew_offer_import(); }
                redirect('crew'); return;
            }
            if (ctype_digit((string)$id)) {
                if (($a[2] ?? '') === 'export')  { crew_offer_export((int)$id); return; }
                if (($a[2] ?? '') === 'migrate' && $method === 'POST') { csrf_check(); crew_offer_migrate((int)$id); return; }
                if (($a[2] ?? '') === 'use'     && $method === 'POST') { csrf_check(); crew_offer_make_from_template((int)$id); return; }
                if (($a[2] ?? '') === 'guest'   && $method === 'POST') { csrf_check(); crew_offer_guest_add((int)$id); return; }
                if (($a[2] ?? '') === 'delview' && $method === 'POST') { csrf_check(); if (!is_owner()) redirect('crew/offers/' . $id); crew_offer_delview((int)$id); return; }
                if (($a[2] ?? '') === 'delete' && $method === 'POST') { csrf_check(); crew_offer_delete((int)$id); return; }
                if (($a[2] ?? '') === 'clone' && $method === 'POST')  { csrf_check(); crew_offer_clone((int)$id); return; }
                $err = null;
                if ($method === 'POST') { csrf_check(); $err = crew_offer_save((int)$id); }
                crew_offer_form((int)$id, $err); return;
            }
            redirect('crew'); return;

        case 'images':
            require_admin(); if (!admin_can('tilbud')) redirect('crew');
            if (($a[1] ?? '') === 'delete' && $method === 'POST') {
                csrf_check();
                json_out(delete_section_image(trim($_POST['path'] ?? '')));
            }
            if (($a[1] ?? '') === 'upload' && $method === 'POST') {
                csrf_check();
                $f = $_FILES['image'] ?? null;
                if (!$f) json_out(['ok' => false, 'error' => 'Ingen fil.'], 400);
                try { $p = store_image_upload($f['tmp_name'], $f['error'], $f['size']); json_out(['ok' => true, 'path' => $p, 'url' => url($p)]); }
                catch (RuntimeException $e) { json_out(['ok' => false, 'error' => $e->getMessage()], 422); }
            }
            redirect('crew'); return;

        case 'admins':
            require_admin();
            $id = $a[1] ?? '';
            // Any crew may ADD a crew member + edit THEIR OWN profile; only the owner may delete or edit others.
            if ($id === 'new' && $method === 'POST') { csrf_check(); crew_admin_save(); return; }
            if (ctype_digit((string)$id)) {
                $sub = $a[2] ?? '';
                if ($sub === 'delete' && $method === 'POST') { csrf_check(); if (!is_owner()) redirect('crew/admins'); crew_admin_delete((int)$id); return; }
                if ($sub === 'edit') {
                    if (!can_edit_admin((int)$id)) redirect('crew/admins');
                    $e = null;
                    if ($method === 'POST') { csrf_check(); $e = crew_admin_update((int)$id); if (!$e) redirect('crew/admins'); }
                    crew_admin_edit_form((int)$id, $e); return;
                }
            }
            crew_admins_view(); return;

        case 'innstillinger':
            require_admin();                                   // theme colours + SMS: any crew member
            $sub = $a[1] ?? '';
            if ($sub === 'reset' && $method === 'POST') { csrf_check(); crew_settings_reset(); return; }
            if ($method === 'POST') { csrf_check(); crew_settings_save(); return; }
            crew_settings_view(); return;

        case 'oppdatering':   // auto-update actions (owner only; the UI lives at the bottom of Innstillinger)
            require_admin(); if (!is_owner() || !defined('UPDATE_KEY') || UPDATE_KEY === '') redirect('crew/innstillinger');
            if (defined('HIDE_UPDATE_UI') && HIDE_UPDATE_UI) redirect('crew/innstillinger');   // UI skjult per config — master styrer siten via /update/run
            require_once dirname(__DIR__) . '/3399cc-plan/lib/update.php';
            if ($method === 'POST') { csrf_check(); crew_update_action($a[1] ?? ''); return; }
            redirect('crew/innstillinger'); return;

        default:
            redirect('crew');
    }
}

/* ---- Crew «Innstillinger»: editable theme colours + SMS country allowlist (owner only) ---- */
function settings_color_fields(): array {
    return [
        'accent'     => 'Primærfarge (aksent)',
        'accent-2'   => 'Sekundærfarge',
        'accent-ink' => 'Tekst på aksentfarge',
        'ink'        => 'Tekstfarge (mørk)',
        'muted'      => 'Dempet tekst',
        'paper'      => 'Bakgrunn (side)',
        'surface'    => 'Kort/flate',
        'surface-2'  => 'Flate 2',
        'hairline'   => 'Linjer/kanter',
    ];
}

function crew_settings_view(): void {
    $t = theme_current();
    render_admin('Innstillinger', view_path('settings.php'), [
        'themeKey'   => $t['_key'] ?? '',
        'themeName'  => $t['name'] ?? '',
        'themeVars'  => $t['vars'] ?? [],
        'defaults'   => theme_default_vars($t['_key'] ?? ''),
        'fields'     => settings_color_fields(),
        'smsCc'      => implode(', ', sms_allowed_cc()),
    ]);
}

function crew_settings_save(): void {
    $t = theme_current(); $key = $t['_key'] ?? '';
    if ($key === '') { flash('Fant ikke aktivt tema.'); redirect('crew/innstillinger'); }
    $scope = 'theme:' . $key;
    foreach (settings_color_fields() as $name => $_label) {
        $v = strtolower(trim((string)($_POST['color'][$name] ?? '')));
        if ($v !== '' && $v[0] !== '#') $v = '#' . $v;
        if (preg_match('/^#[0-9a-f]{6}$/', $v)) setting_set($scope, 'color.' . $name, $v);
    }
    // SMS country allowlist (comma-separated digits, e.g. "47, 46")
    $cc = array_values(array_filter(array_map(fn($c) => preg_replace('/\D/', '', $c), explode(',', (string)($_POST['sms_cc'] ?? ''))), fn($c) => $c !== ''));
    setting_set($scope, 'sms_cc', $cc ? implode(',', $cc) : '47');
    flash('Innstillinger lagret for ' . ($t['name'] ?? $key) . '.');
    redirect('crew/innstillinger');
}

function crew_settings_reset(): void {
    $t = theme_current(); $key = $t['_key'] ?? '';
    if ($key !== '') setting_delete_scope_prefix('theme:' . $key, 'color.');
    flash('Fargene er tilbakestilt til standard.');
    redirect('crew/innstillinger');
}

function render_admin(string $title, string $view, array $vars = []): void {
    extract($vars, EXTR_SKIP);
    $__title = $title; $__view = $view; $user = auth_user();
    require view_path('layout_admin.php');
}

/* ---- Clients (Kunder) ---- */
/** Find a client by phone (else by name), creating one if needed; freshens email/company. Returns id. */
function find_or_create_client(string $name, ?string $email, ?string $phone, ?string $company = null): ?int {
    $name = trim($name);
    $phoneN = ($phone !== null && $phone !== '') ? sms_normalize($phone) : null;
    if ($name === '' || $phoneN === null) return null;   // a client requires a phone (its unique identity)
    $st = db()->prepare('SELECT id FROM clients WHERE phone = ? LIMIT 1'); $st->execute([$phoneN]);
    $id = $st->fetchColumn();
    if ($id) {
        db()->prepare("UPDATE clients SET name=?, email=COALESCE(NULLIF(?,''),email), company=COALESCE(NULLIF(?,''),company) WHERE id=?")
            ->execute([$name, (string)$email, (string)$company, $id]);
        return (int) $id;
    }
    db()->prepare('INSERT INTO clients (name,email,phone,company) VALUES (?,?,?,?)')
        ->execute([$name, $email ?: null, $phoneN, $company ?: null]);
    return (int) db()->lastInsertId();
}

function crew_clients_view(): void {
    $clients = db()->query(
        'SELECT c.*, (SELECT COUNT(*) FROM offers o WHERE o.client_id = c.id) AS offers_count
         FROM clients c ORDER BY c.name'
    )->fetchAll();
    $guests = db()->query(
        "SELECT g.*, o.title AS offer_title, o.hash AS offer_hash, o.client_name
         FROM offer_guests g JOIN offers o ON o.id = g.offer_id
         ORDER BY g.created_at DESC"
    )->fetchAll();
    render_admin('Kunder', view_path('clients.php'), ['clients' => $clients, 'guests' => $guests]);
}


function crew_client_form(int $id, ?string $err = null): void {
    $st = db()->prepare('SELECT * FROM clients WHERE id=?'); $st->execute([$id]);
    $client = $st->fetch();
    if (!$client) redirect('crew/clients');
    $base = 'SELECT o.*, a.name AS sender_name FROM offers o LEFT JOIN admins a ON a.id=o.admin_id WHERE ';
    try {   // include offers where the client is an additional participant (offer_participants migration)
        $st = db()->prepare($base . '(o.client_id = ? OR o.id IN (SELECT offer_id FROM offer_participants WHERE client_id = ?)) ORDER BY o.created_at DESC');
        $st->execute([$id, $id]);
    } catch (PDOException $e) {
        $st = db()->prepare($base . 'o.client_id = ? ORDER BY o.created_at DESC'); $st->execute([$id]);
    }
    render_admin('Kunde: ' . $client['name'], view_path('client_edit.php'), ['client' => $client, 'offers' => $st->fetchAll(), 'err' => $err]);
}

function crew_client_save(int $id): ?string {
    $name = trim($_POST['name'] ?? '');
    $phone = trim($_POST['phone'] ?? '');
    if ($name === '' || $phone === '') return 'Navn og telefon er påkrevd.';
    try {
        db()->prepare('UPDATE clients SET name=?, email=?, phone=?, company=?, notes=? WHERE id=?')
            ->execute([$name, trim($_POST['email'] ?? '') ?: null, sms_normalize($phone),
                       trim($_POST['company'] ?? '') ?: null, trim($_POST['notes'] ?? '') ?: null, $id]);
    } catch (PDOException $e) {
        return $e->getCode() === '23000' ? 'En annen kunde har allerede dette telefonnummeret.' : 'Kunne ikke lagre.';
    }
    flash('Kunde oppdatert.');
    return null;
}

function crew_client_delete(int $id): void {
    db()->prepare('DELETE FROM clients WHERE id=?')->execute([$id]);   // offers keep their snapshot (client_id → NULL)
    flash('Kunde slettet.');
    redirect('crew/clients');
}

function crew_client_create(): void {
    $name = trim($_POST['name'] ?? '');
    $phone = trim($_POST['phone'] ?? '');
    if ($name === '' || $phone === '') { flash('Navn og telefon er påkrevd.'); redirect('crew/clients'); }
    try {
        db()->prepare('INSERT INTO clients (name,email,phone,company) VALUES (?,?,?,?)')
            ->execute([$name, trim($_POST['email'] ?? '') ?: null, sms_normalize($phone), trim($_POST['company'] ?? '') ?: null]);
    } catch (PDOException $e) {
        flash($e->getCode() === '23000' ? 'En kunde med dette telefonnummeret finnes allerede.' : 'Kunne ikke lagre kunde.');
        redirect('crew/clients');
    }
    flash('Kunde lagt til.');
    redirect('crew/clients/' . db()->lastInsertId());
}

/* ---- Admin (crew) management ---- */
function crew_admins_view(): void {
    $admins = db()->query('SELECT * FROM admins ORDER BY created_at')->fetchAll();
    render_admin('Ansatte', view_path('admins.php'), ['admins' => $admins]);
}

function crew_admin_save(): void {
    $name  = trim($_POST['name'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $phone = trim($_POST['phone'] ?? '');
    if ($name === '' || $email === '' || strlen(preg_replace('/\D/', '', $phone)) < 8) {
        flash('Navn, e-post og mobilnummer kreves (mobil brukes til SMS-innlogging).'); redirect('crew/admins');
    }
    try {
        $avatar = handle_upload('avatar', 'avatar');
        $video  = handle_upload('video', 'video');
        $newId = create_admin([
            'name' => $name, 'position' => trim($_POST['position'] ?? ''), 'company' => trim($_POST['company'] ?? ''),
            'email' => $email, 'phone' => $phone, 'password' => '',
            'avatar_path' => $avatar, 'video_path' => $video,
        ]);
        if (!empty($_POST['api_enabled'])) {
            db()->prepare('UPDATE admins SET api_key=? WHERE id=?')->execute([gen_api_key(), $newId]);
        }
        flash('Ansatt lagt til.');
    } catch (PDOException $ex) {
        flash($ex->getCode() === '23000' ? 'E-posten er allerede i bruk.' : 'Kunne ikke lagre.');
    } catch (RuntimeException $ex) {
        flash($ex->getMessage());
    }
    redirect('crew/admins');
}

function crew_admin_delete(int $id): void {
    if (!is_owner()) { flash('Kun Dev (ansatt 1) kan slette ansatte.'); redirect('crew/admins'); }
    if ((int)auth_user()['id'] === $id) { flash('Du kan ikke slette deg selv.'); redirect('crew/admins'); }
    try {
        db()->prepare('DELETE FROM admins WHERE id=?')->execute([$id]);
        flash('Crew-medlem slettet.');
    } catch (PDOException $ex) {
        flash('Kan ikke slette: medlemmet er avsender på ett eller flere tilbud.');
    }
    redirect('crew/admins');
}

function crew_admin_edit_form(int $id, ?string $err = null): void {
    $st = db()->prepare('SELECT * FROM admins WHERE id=?'); $st->execute([$id]);
    $a = $st->fetch();
    if (!$a) redirect('crew/admins');
    render_admin('Rediger crew', view_path('admin_edit.php'), ['a' => $a, 'err' => $err]);
}

function crew_admin_update(int $id): ?string {
    if (!can_edit_admin($id)) redirect('crew/admins');
    $name = trim($_POST['name'] ?? ''); $email = trim($_POST['email'] ?? ''); $phone = trim($_POST['phone'] ?? '');
    if ($name === '' || $email === '' || strlen(preg_replace('/\D/', '', $phone)) < 8) {
        return 'Navn, e-post og mobilnummer kreves.';
    }
    try {
        $avatar = handle_upload('avatar', 'avatar');   // null if no new file
        $video  = handle_upload('video', 'video');
    } catch (RuntimeException $ex) { return $ex->getMessage(); }

    $params = ['name' => $name, 'position' => trim($_POST['position'] ?? '') ?: null,
               'company' => trim($_POST['company'] ?? '') ?: null, 'email' => $email, 'phone' => $phone, 'id' => $id];
    $sql = 'UPDATE admins SET name=:name, position=:position, company=:company, email=:email, phone=:phone';
    // Module access (tilbud/flyt): only the OWNER may change it, and never their own (id 1 = always all).
    if (is_owner() && $id !== 1) {
        $acc = [];
        if (!empty($_POST['access_tilbud'])) $acc[] = 'tilbud';
        if (!empty($_POST['access_flow']))   $acc[] = 'flow';
        try { db()->prepare('UPDATE admins SET access = ? WHERE id = ?')->execute([implode(',', $acc) ?: 'ingen', $id]); }
        catch (PDOException $ex) { /* access column not migrated yet — ignore */ }
    }
    if ($avatar) { $sql .= ', avatar_path=:avatar'; $params['avatar'] = $avatar; }
    if ($video)  { $sql .= ', video_path=:video';   $params['video']  = $video;  }
    // API key: checkbox enables/disables; "regenerate" mints a fresh one.
    $curKey = db()->query('SELECT api_key FROM admins WHERE id=' . (int)$id)->fetchColumn();
    if (!empty($_POST['api_enabled'])) {
        if (!$curKey || !empty($_POST['api_regen'])) { $sql .= ', api_key=:apikey'; $params['apikey'] = gen_api_key(); }
    } elseif ($curKey) {
        $sql .= ', api_key=NULL';   // revoke
    }
    $sql .= ' WHERE id=:id';
    try { db()->prepare($sql)->execute($params); }
    catch (PDOException $ex) { return $ex->getCode() === '23000' ? 'E-posten er allerede i bruk.' : 'Kunne ikke lagre.'; }
    flash('Crew oppdatert.');
    return null;
}

function crew_setup_save(): ?string {
    $name = trim($_POST['name'] ?? ''); $email = trim($_POST['email'] ?? ''); $phone = trim($_POST['phone'] ?? '');
    if ($name === '' || $email === '' || strlen(preg_replace('/\D/', '', $phone)) < 8) {
        return 'Navn, e-post og mobilnummer kreves (mobil brukes til SMS-innlogging).';
    }
    try {
        $avatar = handle_upload('avatar', 'avatar');
        $video  = handle_upload('video', 'video');
    } catch (RuntimeException $ex) { return $ex->getMessage(); }
    $newId = create_admin([
        'name' => $name, 'position' => trim($_POST['position'] ?? ''), 'company' => trim($_POST['company'] ?? ''),
        'email' => $email, 'phone' => $phone, 'password' => '',
        'avatar_path' => $avatar, 'video_path' => $video,
    ]);
    session_regenerate_id(true);
    $_SESSION['admin_id'] = $newId;
    return null;
}

/* ============================================================
   CLIENT PORTAL  (/portal) — SMS login by mobile, then an offer overview
   ============================================================ */
function route_portal(array $a, string $method): void {
    $r = $a[0] ?? '';
    if ($r === 'logout') { unset($_SESSION['portal_phone']); redirect('portal'); }

    // FlowMap: gated "Sitemaps" tab — all logic lives in 3399cc-plan/flow/
    if ($r === 'sitemaps' && FLOWMAP_ENABLED) {
        if (empty($_SESSION['portal_phone'])) redirect('portal');
        require_once dirname(__DIR__) . '/3399cc-plan/flow/lib/routes.php';
        flow_route_portal(array_values(array_slice($a, 1)), $method); return;
    }

    if (!empty($_SESSION['portal_phone'])) { portal_overview(); return; }

    $err = null;
    if ($method === 'POST') {
        csrf_check();
        if (!empty($_SESSION['pending_portal']) && isset($_POST['code'])) {
            if (twofa_check('portal:' . $_SESSION['pending_portal'], $_POST['code'] ?? '')) {
                $_SESSION['portal_phone'] = $_SESSION['pending_portal'];
                unset($_SESSION['pending_portal']); redirect('portal');
            }
            $err = 'Feil eller utløpt kode.';
        } elseif (isset($_POST['cancel'])) {
            unset($_SESSION['pending_portal']);
        } elseif (!sms_enabled()) {
            $err = 'SMS-innlogging er ikke konfigurert ennå.';
        } else {
            $norm = sms_normalize($_POST['telefon'] ?? '');
            $has = false;
            try {
                $cnt = db()->prepare("SELECT COUNT(*) FROM offers o WHERE o.client_phone = ?
                    OR o.id IN (SELECT op.offer_id FROM offer_participants op JOIN clients c ON c.id = op.client_id WHERE c.phone = ?)");
                $cnt->execute([$norm, $norm]); $has = (int)$cnt->fetchColumn() > 0;
            } catch (PDOException $e) {
                $cnt = db()->prepare('SELECT COUNT(*) FROM offers WHERE client_phone = ?'); $cnt->execute([$norm]); $has = (int)$cnt->fetchColumn() > 0;
            }
            // FlowMap: a client with only a flyt (no tilbud yet) may also log in (gated delegation)
            if (!$has && FLOWMAP_ENABLED) {
                require_once dirname(__DIR__) . '/3399cc-plan/flow/lib/routes.php';
                $has = flow_client_has_sitemaps($norm);
            }
            if ($has && twofa_start('portal:' . $norm, $norm)) {
                $_SESSION['pending_portal'] = $norm;
            } else {
                $err = 'Fant ingen tilbud registrert på dette nummeret.';
            }
        }
    }
    $pending = !empty($_SESSION['pending_portal']);
    $maskedPhone = $pending ? mask_phone($_SESSION['pending_portal']) : '';
    require view_path('portal_login.php');
}

function portal_overview(): void {
    $phone = $_SESSION['portal_phone'];
    $base = "SELECT o.*, a.name AS sender_name, a.company AS sender_company
             FROM offers o LEFT JOIN admins a ON a.id = o.admin_id WHERE o.status <> 'draft' AND ";
    try {   // include offers where the phone is an additional participant (offer_participants migration)
        $st = db()->prepare($base . "(o.client_phone = ?
              OR o.id IN (SELECT op.offer_id FROM offer_participants op JOIN clients c ON c.id = op.client_id WHERE c.phone = ?))
              ORDER BY o.created_at DESC");
        $st->execute([$phone, $phone]);
    } catch (PDOException $e) {
        $st = db()->prepare($base . "o.client_phone = ? ORDER BY o.created_at DESC");
        $st->execute([$phone]);
    }
    $offers = $st->fetchAll();
    $clientName = $offers[0]['client_name'] ?? '';
    require view_path('portal.php');
}

/* ---- Upload handling ---- */
function handle_upload(string $field, string $kind): ?string {
    if (empty($_FILES[$field]) || ($_FILES[$field]['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return null;
    $f = $_FILES[$field];
    if ($f['error'] !== UPLOAD_ERR_OK) throw new RuntimeException('Opplasting feilet (kode ' . $f['error'] . ').');
    $max = $kind === 'avatar' ? UPLOAD_MAX_AVATAR : UPLOAD_MAX_VIDEO;
    if ($f['size'] > $max) throw new RuntimeException('Filen er for stor.');
    $ext   = strtolower(pathinfo((string)($f['name'] ?? ''), PATHINFO_EXTENSION));
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mime  = (string) finfo_file($finfo, $f['tmp_name']);
    finfo_close($finfo);

    if ($kind === 'avatar') {
        $byMime = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
        $byExt  = ['jpg' => 'jpg', 'jpeg' => 'jpg', 'png' => 'png', 'webp' => 'webp'];
        $out = $byMime[$mime] ?? ($byExt[$ext] ?? null);
        if ($out === null) throw new RuntimeException('Ugyldig bildetype (bruk jpg, png eller webp).');
        $sub = 'avatars';
    } else {
        // MP4/MOV containers are reported inconsistently by finfo (video/mp4, video/x-m4v,
        // application/mp4, audio/mp4, even application/octet-stream). Accept any video/* MIME,
        // or a generic/stream MIME when the file extension is a known video type.
        $byExt   = ['mp4' => 'mp4', 'm4v' => 'mp4', 'mov' => 'mov', 'webm' => 'webm'];
        $generic = (strpos($mime, 'video/') === 0)
                || in_array($mime, ['application/mp4', 'audio/mp4', 'application/octet-stream', ''], true);
        $out = ($generic && isset($byExt[$ext])) ? $byExt[$ext] : null;
        if ($out === null) throw new RuntimeException('Ugyldig filtype (bruk mp4, mov eller webm).');
        $sub = 'videos';
    }

    $dir = __DIR__ . '/uploads/' . $sub;
    if (!is_dir($dir)) mkdir($dir, 0775, true);
    $name = bin2hex(random_bytes(8)) . '.' . $out;
    if (!move_uploaded_file($f['tmp_name'], $dir . '/' . $name)) throw new RuntimeException('Kunne ikke lagre filen.');
    return 'uploads/' . $sub . '/' . $name;
}

