using Forge.Web.Components;
using Forge.Web.GitHttp;
using Forge.Web.Auth;
using Forge.Web.Services;
using Forge.Data;
using Forge.Data.Services;
using Forge.Core.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Fido2NetLib;
using Microsoft.AspNetCore.HttpOverrides;
using Forge.Core.Models;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents(options => options.DetailedErrors = true);
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedProto;
    options.KnownNetworks.Clear();
    options.KnownProxies.Clear();
});

builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection(AuthOptions.SectionName));
builder.Services.AddScoped<IAuthService, ConfiguredAuthService>();
builder.Services.AddSingleton<MarkdownService>();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.LoginPath = "/login";
        options.AccessDeniedPath = "/login";
        options.SlidingExpiration = true;
    });
builder.Services.AddAuthorization();

var workspaceRoot = ResolveWorkspaceRoot(builder.Environment.ContentRootPath);

// Configure database
var dbPath = builder.Configuration.GetValue<string>("Database:Path")
    ?? Path.Combine(workspaceRoot, "forge.db");
builder.Services.AddDbContext<ForgeDbContext>(options =>
    options.UseSqlite($"Data Source={dbPath}"));

// Configure git repositories root
var reposRoot = builder.Configuration.GetValue<string>("Repositories:Root")
    ?? Path.Combine(workspaceRoot, "repositories");
Directory.CreateDirectory(reposRoot);

// Register services
builder.Services.AddSingleton<IGitService>(sp => new GitService(reposRoot));
builder.Services.AddScoped<IRepositoryService, RepositoryService>();

// Configure Fido2
builder.Services.AddScoped<IFido2>(sp =>
{
    var httpContextAccessor = sp.GetRequiredService<IHttpContextAccessor>();
    var configuration = sp.GetRequiredService<IConfiguration>();
    var request = httpContextAccessor.HttpContext?.Request;
    var origin = ResolveWebAuthnOrigin(request, configuration["BaseUrl"]);
    var serverDomain = ResolveWebAuthnServerDomain(request, configuration["BaseUrl"]);

    var fido2Config = new Fido2Configuration
    {
        ServerDomain = serverDomain,
        ServerName = "Forge",
        Origins = new HashSet<string> { origin },
        TimestampDriftTolerance = 300000
    };

    return new Fido2NetLib.Fido2(fido2Config);
});
builder.Services.AddScoped<IFido2Service, Fido2Service>();

// Git HTTP middleware
builder.Services.AddScoped<GitHttpMiddleware>(sp => 
    new GitHttpMiddleware(
        sp.GetRequiredService<IRepositoryService>(),
        sp.GetRequiredService<IGitService>(),
        sp.GetRequiredService<IAuthService>(),
        reposRoot
    )
);

var app = builder.Build();

// Ensure database is created and validate repositories
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<ForgeDbContext>();
    db.Database.EnsureCreated();
    
    // Ensure PasskeyCredentials table exists (for existing databases)
    var connection = db.Database.GetDbConnection();
    await connection.OpenAsync();
    using var cmd = connection.CreateCommand();
    cmd.CommandText = """
        CREATE TABLE IF NOT EXISTS PasskeyCredentials (
            Id TEXT PRIMARY KEY,
            Username TEXT NOT NULL,
            CredentialId BLOB NOT NULL UNIQUE,
            PublicKey BLOB NOT NULL,
            SignCount INTEGER NOT NULL,
            Name TEXT,
            CreatedAt TEXT NOT NULL,
            LastUsedAt TEXT
        );
        CREATE INDEX IF NOT EXISTS IX_PasskeyCredentials_Username ON PasskeyCredentials (Username);
        CREATE INDEX IF NOT EXISTS IX_PasskeyCredentials_CredentialId ON PasskeyCredentials (CredentialId);
        CREATE TABLE IF NOT EXISTS UserAccounts (
            Id TEXT PRIMARY KEY,
            Username TEXT NOT NULL,
            PasswordHash TEXT NOT NULL,
            IsAdmin INTEGER NOT NULL DEFAULT 0,
            Role TEXT NOT NULL DEFAULT 'User',
            CreatedAt TEXT NOT NULL
        );
        CREATE UNIQUE INDEX IF NOT EXISTS IX_UserAccounts_Username ON UserAccounts (Username);
        """;
    await cmd.ExecuteNonQueryAsync();
    try
    {
        cmd.CommandText = "ALTER TABLE UserAccounts ADD COLUMN Role TEXT NOT NULL DEFAULT 'User';";
        await cmd.ExecuteNonQueryAsync();
    }
    catch
    {
        // Column already exists on newer databases.
    }
    cmd.CommandText = """
        UPDATE UserAccounts
        SET Role = CASE WHEN IsAdmin = 1 THEN 'Admin' ELSE 'User' END
        WHERE Role IS NULL OR Role = '';
        """;
    await cmd.ExecuteNonQueryAsync();

    var authService = scope.ServiceProvider.GetRequiredService<IAuthService>();
    await authService.EnsureConfiguredAdminAsync();

    // Validate all repos in DB exist on disk, repair any missing
    var gitService = scope.ServiceProvider.GetRequiredService<IGitService>();
    var repoService = scope.ServiceProvider.GetRequiredService<IRepositoryService>();
    var imported = await repoService.SyncFromDiskAsync(reposRoot);
    if (imported > 0)
    {
        Console.WriteLine($"[Forge] Imported {imported} repositories from disk");
    }

    var repos = await repoService.GetAllAsync();
    var repaired = await gitService.ValidateAndRepairRepositoriesAsync(repos);
    if (repaired > 0)
    {
        Console.WriteLine($"[Forge] Repaired {repaired} missing repositories");
    }
}

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    app.UseHsts();
}

app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();

app.MapStaticAssets();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.MapPost("/auth/login", async (HttpContext context, [FromServices] IAuthService authService) =>
{
    var form = await context.Request.ReadFormAsync();
    var username = form["username"].ToString();
    var password = form["password"].ToString();
    var returnUrl = form["returnUrl"].ToString();

    var user = await authService.ValidateCredentialsAsync(username, password);
    if (user is null)
    {
        var target = string.IsNullOrWhiteSpace(returnUrl) ? "/login?error=true" : $"/login?error=true&returnUrl={Uri.EscapeDataString(returnUrl)}";
        return Results.LocalRedirect(target);
    }

    var claims = new List<Claim>
    {
        new(ClaimTypes.Name, user.Username)
    };

    if (!string.IsNullOrWhiteSpace(user.Role))
    {
        claims.Add(new(ClaimTypes.Role, user.Role));
    }

    var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
    var principal = new ClaimsPrincipal(identity);

    await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);

    if (!string.IsNullOrWhiteSpace(returnUrl) && Uri.IsWellFormedUriString(returnUrl, UriKind.Relative) && returnUrl.StartsWith('/'))
    {
        return Results.LocalRedirect(returnUrl);
    }

    return Results.LocalRedirect(GetDefaultPostLoginPath(user.Username, user.Role));
});

app.MapPost("/auth/logout", async (HttpContext context) =>
{
    await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    return Results.LocalRedirect("/");
});

// WebAuthn / Passkey endpoints
app.MapPost("/auth/passkey/register/start", async (
    HttpContext context,
    [FromServices] IFido2Service fido2Service) =>
{
    var username = context.User.Identity?.Name;
    if (string.IsNullOrEmpty(username))
    {
        return Results.Unauthorized();
    }
    
    var options = await fido2Service.StartRegistrationAsync(username);
    
    // Return options as JSON (Fido2NetLib handles serialization)
    return Results.Json(options);
});

app.MapPost("/auth/passkey/register/complete", async (
    HttpContext context,
    [FromServices] IFido2Service fido2Service) =>
{
    var username = context.User.Identity?.Name;
    if (string.IsNullOrEmpty(username))
    {
        return Results.Unauthorized();
    }
    
    try
    {
        // Parse JSON manually to avoid base64url conversion issues
        using var reader = new StreamReader(context.Request.Body);
        var json = await reader.ReadToEndAsync();
        var node = System.Text.Json.Nodes.JsonNode.Parse(json);
        
        var response = new Fido2NetLib.AuthenticatorAttestationRawResponse
        {
            Id = node!["id"]!.GetValue<string>(),
            RawId = Base64Url.Decode(node["rawId"]!.GetValue<string>()),
            Type = Fido2NetLib.Objects.PublicKeyCredentialType.PublicKey,
            Response = new Fido2NetLib.AuthenticatorAttestationRawResponse.AttestationResponse
            {
                ClientDataJson = Base64Url.Decode(node["response"]!["clientDataJSON"]!.GetValue<string>()),
                AttestationObject = Base64Url.Decode(node["response"]!["attestationObject"]!.GetValue<string>()),
                Transports = []
            },
            ClientExtensionResults = new Fido2NetLib.Objects.AuthenticationExtensionsClientOutputs()
        };
        
        var deviceName = node["deviceName"]?.GetValue<string>();
        var credential = await fido2Service.CompleteRegistrationAsync(username, response, deviceName);
        return Results.Json(new { success = true, credentialId = credential.Id });
    }
    catch (Exception ex)
    {
        return Results.BadRequest(new { error = ex.Message });
    }
});

app.MapGet("/auth/passkey/authenticate/start", async (
    [FromServices] IFido2Service fido2Service) =>
{
    var options = await fido2Service.StartAuthenticationAsync();
    return Results.Json(options);
});

app.MapPost("/auth/passkey/authenticate/complete", async (
    HttpContext context,
    [FromServices] IFido2Service fido2Service,
    [FromServices] IAuthService authService) =>
{
    try
    {
        // Parse JSON manually to avoid base64url conversion issues
        using var reader = new StreamReader(context.Request.Body);
        var json = await reader.ReadToEndAsync();
        var node = System.Text.Json.Nodes.JsonNode.Parse(json);
        
        var response = new Fido2NetLib.AuthenticatorAssertionRawResponse
        {
            Id = node!["id"]!.GetValue<string>(),
            RawId = Base64Url.Decode(node["rawId"]!.GetValue<string>()),
            Type = Fido2NetLib.Objects.PublicKeyCredentialType.PublicKey,
            Response = new Fido2NetLib.AuthenticatorAssertionRawResponse.AssertionResponse
            {
                ClientDataJson = Base64Url.Decode(node["response"]!["clientDataJSON"]!.GetValue<string>()),
                AuthenticatorData = Base64Url.Decode(node["response"]!["authenticatorData"]!.GetValue<string>()),
                Signature = Base64Url.Decode(node["response"]!["signature"]!.GetValue<string>()),
                UserHandle = node["response"]?["userHandle"]?.GetValue<string>() is string uh ? Base64Url.Decode(uh) : null
            },
            ClientExtensionResults = new Fido2NetLib.Objects.AuthenticationExtensionsClientOutputs()
        };
        
        var username = await fido2Service.CompleteAuthenticationAsync(response);
        
        if (username == null)
        {
            return Results.Json(new { success = false, error = "Authentication failed" });
        }

        var user = await authService.GetUserAsync(username);
        if (user is null)
        {
            return Results.Json(new { success = false, error = "User no longer exists" });
        }

        var requestedReturnUrl = node["returnUrl"]?.GetValue<string>();
        
        // Sign the user in
        var claims = new List<Claim>
        {
            new(ClaimTypes.Name, user.Username)
        };

        if (!string.IsNullOrWhiteSpace(user.Role))
        {
            claims.Add(new(ClaimTypes.Role, user.Role));
        }
        
        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
        var principal = new ClaimsPrincipal(identity);
        
        await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
        
        var redirect = !string.IsNullOrWhiteSpace(requestedReturnUrl)
            && Uri.IsWellFormedUriString(requestedReturnUrl, UriKind.Relative)
            && requestedReturnUrl.StartsWith('/')
                ? requestedReturnUrl
                : GetDefaultPostLoginPath(user.Username, user.Role);

        return Results.Json(new { success = true, redirect });
    }
    catch (Exception ex)
    {
        return Results.BadRequest(new { error = ex.Message });
    }
});

app.MapGet("/auth/passkey/credentials", async (
    HttpContext context,
    [FromServices] IFido2Service fido2Service,
    [FromServices] IAuthService authService) =>
{
    var username = context.User.Identity?.Name;
    if (string.IsNullOrEmpty(username))
    {
        return Results.Unauthorized();
    }
    
    var credentials = await fido2Service.GetCredentialsAsync(username);
    return Results.Json(credentials.Select(c => new {
        c.Id,
        c.Name,
        c.CreatedAt,
        c.LastUsedAt
    }));
});

app.MapDelete("/auth/passkey/credentials/{id}", async (
    HttpContext context,
    Guid id,
    [FromServices] IFido2Service fido2Service,
    [FromServices] IAuthService authService) =>
{
    var username = context.User.Identity?.Name;
    if (string.IsNullOrEmpty(username))
    {
        return Results.Unauthorized();
    }
    
    await fido2Service.DeleteCredentialAsync(id, username);
    return Results.Json(new { success = true });
});

// Git Smart HTTP endpoints
app.MapMethods("/{owner}/{repo}.git/{**rest}", new[] { "GET", "POST" }, async (HttpContext context, string owner, string repo,
    [FromServices] GitHttpMiddleware git) =>
{
    await git.HandleAsync(context, owner, repo);
});

app.Run();

static string ResolveWebAuthnOrigin(HttpRequest? request, string? configuredBaseUrl)
{
    if (request is not null && request.Host.HasValue)
    {
        return $"{request.Scheme}://{request.Host.Value}".TrimEnd('/');
    }

    return (configuredBaseUrl ?? "http://localhost:5128").TrimEnd('/');
}

static string ResolveWebAuthnServerDomain(HttpRequest? request, string? configuredBaseUrl)
{
    if (request is not null && request.Host.HasValue)
    {
        return request.Host.Host;
    }

    return new Uri(configuredBaseUrl ?? "http://localhost:5128").Host;
}

static string ResolveWorkspaceRoot(string contentRootPath)
{
    var current = new DirectoryInfo(contentRootPath);

    while (current is not null)
    {
        var gitDir = Path.Combine(current.FullName, ".git");
        var flakeFile = Path.Combine(current.FullName, "flake.nix");

        if (Directory.Exists(gitDir) || File.Exists(flakeFile))
        {
            return current.FullName;
        }

        current = current.Parent;
    }

    return contentRootPath;
}

static string GetDefaultPostLoginPath(string username, string role) =>
    string.Equals(role, UserRoles.Admin, StringComparison.Ordinal) ? "/admin" : $"/{Uri.EscapeDataString(username)}";

file static class Base64Url
{
    public static byte[] Decode(string s) => 
        Convert.FromBase64String(s.Replace('-', '+').Replace('_', '/').PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
An unhandled error has occurred. Reload 🗙