<?php
declare(strict_types=1);

/*
 * foxygit — a mostly-read-only git web frontend. No database, no deps.
 *
 * Reads bare repos by shelling out to the `git` binary. git push/pull itself
 * is NOT handled here — that's SSH (see server/) or authenticated HTTPS via
 * git-http-backend (see server/git-http-backend-auth). This app is the
 * "shop window" (browse repos, log in, manage API keys, admin repo
 * create/delete) plus the one shared source of truth (inc/auth.php) that
 * the HTTPS-push auth check also reads from.
 *
 * This file is the router only: it resolves input, asks inc/functions.php
 * to talk to git, and hands plain-array results to views/*.php to render.
 * No HTML lives here — see views/ for that, and assets/+themes/ for CSS.
 *
 * Deploy: point php-fpm + Caddy at this file, set REPO_BASE in inc/config.php,
 * make sure the php-fpm pool user can READ the bare repos (see deploy.sh).
 */

require __DIR__ . '/inc/config.php';
require __DIR__ . '/inc/functions.php';
require __DIR__ . '/inc/render.php';
require __DIR__ . '/inc/auth.php';

/* ---------------------------------------------------------------- routing */

$repoName = $_GET['r'] ?? null;
$repo     = $repoName !== null ? resolve_repo($repoName) : null;
// A repo with no explicit view lands on Files (tree), the way GitHub opens on Code.
$action   = $_GET['a'] ?? ($repoName !== null ? 'tree' : 'index');

// Resume a session if the visitor already has one (so the nav can show who's
// logged in on any page), but never create one just for anonymous browsing —
// that stays exactly as cookie-light as it's always been. The auth routes
// below force a session into existence (needed for CSRF tokens + login itself).
auth_session_start();
if (in_array($action, ['setup', 'login', 'logout', 'account', 'admin'], true)) {
    auth_session_start(force: true);
}

if ($repoName !== null && $repo === null) {
    http_response_code(404);
    $action = 'notfound';
}

$themes = available_themes();
$theme  = current_theme();
if (isset($_GET['theme']) && $_GET['theme'] === $theme) {   // explicit, valid choice -> remember it
    setcookie('foxygit_theme', $theme, [
        'expires' => time() + 31536000, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
    ]);
}

/* ----------------------------------------------------------------- views */

if ($action === 'help') {                                // global page, works with or without ?r=
    render('partials/head', ['title' => SITE_NAME . ' · help', 'theme' => $theme, 'repoName' => null]);
    render('help');
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

if ($action === 'notfound') {
    render('partials/head', ['title' => '404', 'theme' => $theme, 'repoName' => null]);
    render('notfound');
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

/* -------------------------------------------------------- accounts/admin */

if ($action === 'setup') {                              // one-time: create the first (admin) account
    if (has_any_users()) { header('Location: ?a=login'); exit; }

    $error = null;
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        check_csrf();
        $username = trim((string) ($_POST['username'] ?? ''));
        $password = (string) ($_POST['password'] ?? '');
        $confirm  = (string) ($_POST['confirm'] ?? '');
        if ($password !== $confirm) {
            $error = 'Passwords do not match.';
        } else {
            $result = create_user($username, $password, true);
            if ($result === true) {
                log_in_session($username, true);
                header('Location: ?a=account');
                exit;
            }
            $error = $result;
        }
    }

    render('partials/head', ['title' => SITE_NAME . ' · set up', 'theme' => $theme, 'repoName' => null]);
    render('setup', ['error' => $error, 'csrf' => csrf_token()]);
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

if ($action === 'login') {
    if (!has_any_users()) { header('Location: ?a=setup'); exit; }

    $error = null;
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        check_csrf();
        $username = trim((string) ($_POST['username'] ?? ''));
        $password = (string) ($_POST['password'] ?? '');
        if (verify_login($username, $password)) {
            $users   = list_users();
            $isAdmin = (bool) ($users[$username]['is_admin'] ?? false);
            log_in_session($username, $isAdmin);
            // only ever redirect to a same-page query string (must start with "?") --
            // anything else (an absolute/protocol-relative URL) is rejected so a
            // crafted ?next= can't turn a real login into an open redirect.
            $next = $_POST['next'] ?? '';
            $target = is_string($next) && str_starts_with($next, '?') ? $next : '?';
            header('Location: ' . $target);
            exit;
        }
        // deliberately vague — doesn't reveal whether the username exists
        $error = 'Invalid username or password.';
    }

    render('partials/head', ['title' => SITE_NAME . ' · log in', 'theme' => $theme, 'repoName' => null]);
    render('login', ['error' => $error, 'csrf' => csrf_token(), 'next' => (string) ($_GET['next'] ?? '')]);
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

if ($action === 'logout') {
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        check_csrf();
        log_out_session();
    }
    header('Location: ?');
    exit;
}

if ($action === 'account') {
    require_login();
    $username = (string) current_username();

    $newKey = null;
    $error  = null;
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        check_csrf();
        $sub = $_POST['sub'] ?? '';
        if ($sub === 'create-key') {
            $label  = trim((string) ($_POST['label'] ?? ''));
            $newKey = generate_api_key($username, $label);
        } elseif ($sub === 'revoke-key') {
            $hash = (string) ($_POST['hash'] ?? '');
            if (!revoke_api_key($hash, $username, is_admin())) {
                $error = 'Could not revoke that key.';
            }
        }
    }

    render('partials/head', ['title' => SITE_NAME . ' · account', 'theme' => $theme, 'repoName' => null]);
    render('account', [
        'username' => $username, 'isAdmin' => is_admin(), 'keys' => list_api_keys($username),
        'newKey' => $newKey, 'error' => $error, 'csrf' => csrf_token(),
    ]);
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

if ($action === 'admin') {
    require_admin();
    $username = (string) current_username();

    $error   = null;
    $success = null;
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        check_csrf();
        $sub = $_POST['sub'] ?? '';
        if ($sub === 'create-repo') {
            $name   = trim((string) ($_POST['name'] ?? ''));
            $result = create_bare_repo($name);
            if ($result === true) { $success = "Created \"$name\"."; } else { $error = $result; }
        } elseif ($sub === 'delete-repo') {
            $name    = trim((string) ($_POST['name'] ?? ''));
            $confirm = trim((string) ($_POST['confirm'] ?? ''));
            if ($name === '' || $name !== $confirm) {
                $error = 'Repo name did not match its confirmation — nothing deleted.';
            } else {
                $result = delete_bare_repo($name);
                if ($result === true) { $success = "Deleted \"$name\"."; } else { $error = $result; }
            }
        } elseif ($sub === 'add-user') {
            $newUsername = trim((string) ($_POST['username'] ?? ''));
            $newPassword = (string) ($_POST['password'] ?? '');
            $newIsAdmin  = isset($_POST['is_admin']);
            $result = create_user($newUsername, $newPassword, $newIsAdmin);
            if ($result === true) { $success = "Created account \"$newUsername\"."; } else { $error = $result; }
        } elseif ($sub === 'delete-user') {
            $targetUsername = trim((string) ($_POST['username'] ?? ''));
            $result = delete_user($targetUsername, $username);
            if ($result === true) { $success = "Deleted account \"$targetUsername\"."; } else { $error = $result; }
        }
    }

    render('partials/head', ['title' => SITE_NAME . ' · admin', 'theme' => $theme, 'repoName' => null]);
    render('admin', [
        'repos' => list_repos(), 'users' => list_users(), 'currentUsername' => $username,
        'error' => $error, 'success' => $success, 'csrf' => csrf_token(),
    ]);
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

if ($repo === null) {                                   // repo index
    render('partials/head', [
        'title' => SITE_NAME, 'theme' => $theme, 'repoName' => null, 'description' => SITE_DESCRIPTION,
    ]);
    render('repo-index', ['repos' => list_repos()]);
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

/* --- repo is valid & resolved from here on --- */

$description = repo_description($repo);

if ($action === 'raw') {                                 // raw/plain file download — no view, just bytes
    $ref  = $_GET['ref'] ?? 'HEAD';
    $blob = $_GET['blob'] ?? '';
    if (!safe_ref($ref)) $ref = 'HEAD';
    if (!safe_ref($blob)) { http_response_code(400); exit('Invalid path.'); }

    $spec    = $ref . ':' . $blob;
    $sizeOut = git($repo, ['cat-file', '-s', $spec]);
    if (!isset($sizeOut[0])) { http_response_code(404); exit('No such file.'); }

    $content = git_bytes($repo, ['show', $spec]);
    $mime = 'application/octet-stream';
    if (function_exists('finfo_open')) {
        $fi = finfo_open(FILEINFO_MIME_TYPE);
        if ($fi !== false) {
            $detected = finfo_buffer($fi, $content);
            finfo_close($fi);
            if ($detected) $mime = $detected;
        }
    }
    $disposition = isset($_GET['dl']) ? 'attachment' : 'inline';
    header('Content-Type: ' . $mime);
    header('Content-Length: ' . (string) strlen($content));
    header('Content-Disposition: ' . $disposition . '; filename="' . basename($blob) . '"');
    header('X-Content-Type-Options: nosniff');
    echo $content;
    exit;
}

if ($action === 'atom' || $action === 'atom-tags') {
    header('Content-Type: application/atom+xml; charset=utf-8');
    if ($action === 'atom') {
        $ref = $_GET['ref'] ?? 'HEAD';
        if (!safe_ref($ref)) $ref = 'HEAD';
        render('atom', ['repoName' => $repoName, 'entries' => atom_commit_entries($repo, $ref)]);
    } else {
        render('atom-tags', ['repoName' => $repoName, 'entries' => atom_tag_entries($repo)]);
    }
    exit;
}

if ($action === 'commit') {
    $hash = $_GET['h'] ?? '';
    render('partials/head', [
        'title' => repo_display_name($repoName) . ' · commit', 'theme' => $theme, 'repoName' => $repoName,
        'tab' => 'log', 'description' => $description,
    ]);

    if (!preg_match('~^[0-9a-f]{4,64}$~i', $hash)) {    // only hex object ids
        http_response_code(400);
        render('partials/error', ['message' => 'Invalid commit id.']);
        render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
        exit;
    }

    $diffRaw   = git_raw($repo, ['show', '--stat', '-p', '--pretty=fuller', $hash]);
    $truncated = strlen($diffRaw) > MAX_DIFF_BYTES;
    if ($truncated) $diffRaw = substr($diffRaw, 0, MAX_DIFF_BYTES);

    render('commit', ['diff' => colorize_diff($diffRaw), 'truncated' => $truncated]);
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

if ($action === 'refs') {
    // for-each-ref's --format doesn't understand pretty-format's %xNN hex escapes (only
    // `git log`/`show --pretty=format:` do) -- needs a real \x1f byte in the PHP string itself.
    $refs = parse_refs(git($repo, ['for-each-ref', '--sort=-creatordate',
        "--format=%(refname:short)\x1f%(refname)", 'refs/heads', 'refs/tags']));

    render('partials/head', [
        'title' => repo_display_name($repoName) . ' · refs', 'theme' => $theme, 'repoName' => $repoName,
        'tab' => 'refs', 'description' => $description,
    ]);
    render('partials/repo-subnav', [
        'repoName' => $repoName, 'curAction' => 'refs', 'curRef' => 'HEAD', 'branches' => [],
    ]);
    render('refs', ['repoName' => $repoName, 'refs' => $refs]);
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

if ($action === 'log') {
    $ref = $_GET['ref'] ?? 'HEAD';
    if (!safe_ref($ref)) $ref = 'HEAD';
    $skip = isset($_GET['skip']) ? max(0, (int) $_GET['skip']) : 0;

    $lines = git($repo, ['log', '-n', (string) (LOG_COUNT + 1), '--skip', (string) $skip,
        '--pretty=format:%H%x1f%h%x1f%an%x1f%at%x1f%s', $ref]);
    $hasMore = count($lines) > LOG_COUNT;
    $entries = parse_log_lines(array_slice($lines, 0, LOG_COUNT));

    render('partials/head', [
        'title' => repo_display_name($repoName) . ' · commits', 'theme' => $theme, 'repoName' => $repoName,
        'tab' => 'log', 'description' => $description,
    ]);
    render('partials/repo-subnav', [
        'repoName' => $repoName, 'curAction' => 'log', 'curRef' => $ref, 'branches' => list_branches($repo),
    ]);
    render('log', [
        'repoName' => $repoName, 'ref' => $ref, 'skip' => $skip,
        'dateGroups' => group_log_by_date($entries), 'hasMore' => $hasMore,
    ]);
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

/* default: tree (Files) */
$ref = $_GET['ref'] ?? 'HEAD';
if (!safe_ref($ref)) $ref = 'HEAD';

render('partials/head', [
    'title' => repo_display_name($repoName) . ' · files', 'theme' => $theme, 'repoName' => $repoName,
    'tab' => 'tree', 'description' => $description,
]);
render('partials/repo-subnav', [
    'repoName' => $repoName, 'curAction' => 'tree', 'curRef' => $ref, 'branches' => list_branches($repo),
]);

if (isset($_GET['blob'])) {                             // single file contents
    $blob = $_GET['blob'];
    if (!safe_ref($blob)) {
        http_response_code(400);
        render('partials/error', ['message' => 'Invalid path.']);
        render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
        exit;
    }
    $spec    = $ref . ':' . $blob;
    $sizeOut = git($repo, ['cat-file', '-s', $spec]);
    if (!isset($sizeOut[0])) {
        http_response_code(404);
        render('partials/error', ['message' => 'No such file.']);
        render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
        exit;
    }
    $size       = (int) $sizeOut[0];
    $isImage    = is_image_ext($blob);
    $isMarkdown = is_markdown_ext($blob);
    // images are shown via <img src="raw-url">, never dumped inline as text,
    // so the size cap (meant to protect the "paste it into a <pre>" path) doesn't apply to them
    $tooLarge   = !$isImage && $size > MAX_BLOB_BYTES;
    $content    = ($tooLarge || $isImage) ? '' : git_bytes($repo, ['show', $spec]);
    $isBinary   = !$tooLarge && !$isImage && strpos($content, "\0") !== false;

    $dir          = dirname($blob);
    $renderedHtml = ($isMarkdown && !$tooLarge && !$isBinary)
        ? markdown_to_html($content, $repoName, $ref, $dir === '.' ? '' : $dir)
        : null;

    render('blob', [
        'repoName' => $repoName, 'ref' => $ref, 'blob' => $blob, 'size' => $size,
        'tooLarge' => $tooLarge, 'isBinary' => $isBinary, 'isImage' => $isImage,
        'content' => $content, 'renderedHtml' => $renderedHtml,
    ]);
    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
    exit;
}

$path = $_GET['path'] ?? '';
if ($path !== '' && !safe_ref($path)) $path = '';
$treeish = $path === '' ? $ref : "$ref:$path";
$rawTree = git_bytes($repo, ['ls-tree', '--long', '-z', $treeish]);

// README/LICENSE belong to the repo root, so they only show on the top-level tree
$docs = $path === '' ? root_docs($repo, $repoName, $ref) : ['readmeName' => null, 'readmeHtml' => null, 'licenseName' => null];

render('tree', [
    'repoName' => $repoName, 'ref' => $ref, 'path' => $path,
    'entries' => annotate_last_commits($repo, $ref, parse_tree($rawTree, $path)),
] + $docs);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);

/*
 * -------------------------------------------------------------- DEPLOYMENT
 *
 * This server's bare repos live at /var/git/repos, owned git:git, mode 0700
 * (set up by server/setup-server.sh). php-fpm's default pool runs as
 * www-data, which can't read them — so this needs its own pool running as
 * the `git` user instead. See deploy.sh in this directory, which does the
 * whole thing (pool + web root incl. inc/, views/, assets/, themes/ + Caddy
 * block + reload):
 *
 *      sudo bash /home/mrfox/foxygit/deploy.sh
 *
 * Make sure exec() and proc_open() are not in php.ini's disable_functions
 * (they aren't by default on Debian). That's the whole thing.
 */
