// Subcontext access tokens in .NET 8. Gets a token, sends a lead, or runs a
// small token endpoint for your web pages. Pick one with the first argument:
//
//   dotnet run -- get-token      gets a token and shows it is reused from memory
//   dotnet run -- send-lead      sends one lead to a campaign, then checks what became of it
//   dotnet run -- token-server   answers GET (or POST) http://localhost:8081/subcontext-token
//
// An access token lets your code use the Subcontext API for two hours.
// You get one by sending your API application's client id
// and client secret to Subcontext (an OAuth "client credentials" request).
// Run this on your server, never in a web page, because it holds the secret.
//
// Settings come from environment variables:
//
// SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET: from the Subcontext
//   dashboard, Integrations, API applications. The client secret is shown only
//   once, when you create the application (or rotate its secret).
//   For send-lead, use an application with no allowed origins (server-only)
//   and the read, write and crm scopes. For token-server, use one with your
//   website under "Allowed origins" and only read and write.
// SUBCONTEXT_SCOPE (optional, get-token): scopes to ask for, default "read".
// SUBCONTEXT_CAMPAIGN_KEY (send-lead): the campaign the lead belongs to. In the
//   dashboard, CRM, Campaigns, open the campaign. Its key is under its name.
// ALLOWED_ORIGIN (optional, token-server): the website whose pages may ask for
//   a token. Default http://localhost:4100. It must also be in the API
//   application's allowed origins.
// PORT (optional, token-server): default 8081.
// SUBCONTEXT_BASE_URL (optional): the Subcontext address. Default
//   https://subcontext.com.
//
// Needs the .NET 8 SDK. No NuGet packages.

using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;

var mode = args.FirstOrDefault() ?? "";
var baseUrl = (Environment.GetEnvironmentVariable("SUBCONTEXT_BASE_URL") ?? "https://subcontext.com").TrimEnd('/');
var clientIdSetting = Environment.GetEnvironmentVariable("SUBCONTEXT_CLIENT_ID");
var clientSecretSetting = Environment.GetEnvironmentVariable("SUBCONTEXT_CLIENT_SECRET");

if (mode is not ("get-token" or "send-lead" or "token-server"))
{
    Console.Error.WriteLine("Choose one: dotnet run -- get-token | send-lead | token-server");
    return 1;
}
if (string.IsNullOrEmpty(clientIdSetting) || string.IsNullOrEmpty(clientSecretSetting))
{
    Console.Error.WriteLine("Set SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET (see the comment at the top of Program.cs).");
    return 1;
}
string clientId = clientIdSetting, clientSecret = clientSecretSetting;

// One HttpClient for the whole program, as .NET recommends.
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };

try
{
    return mode switch
    {
        "get-token" => await GetTokenExample(),
        "send-lead" => await SendLeadExample(),
        _ => await TokenServer(args),
    };
}
catch (TokenRequestException error)
{
    Console.Error.WriteLine(error.Message);
    return 1;
}

// get-token gets a token twice and shows the second one came from memory.
async Task<int> GetTokenExample()
{
    var tokens = new SubcontextTokens(http, baseUrl, clientId, clientSecret,
        Environment.GetEnvironmentVariable("SUBCONTEXT_SCOPE") ?? "read");
    var first = await tokens.GetAsync();
    Console.WriteLine($"Got a token: {first.AccessToken[..8]}...");
    Console.WriteLine($"It runs out at {first.ExpiresAt:u}");
    var second = await tokens.GetAsync();
    Console.WriteLine(second.AccessToken == first.AccessToken
        ? "Asked again: the same token came from memory, no new request."
        : "Asked again: got a new token.");
    return 0;
}

// send-lead gets a token, sends one lead with an Idempotency-Key, then checks what became of it.
// This sends a real lead. Put your own name and email address in the lead below.
async Task<int> SendLeadExample()
{
    var campaignKey = Environment.GetEnvironmentVariable("SUBCONTEXT_CAMPAIGN_KEY");
    if (string.IsNullOrEmpty(campaignKey))
    {
        Console.Error.WriteLine("Set SUBCONTEXT_CAMPAIGN_KEY (see the comment at the top of Program.cs).");
        return 1;
    }

    // write and crm are both needed to send leads.
    var tokens = new SubcontextTokens(http, baseUrl, clientId, clientSecret, "read write crm");
    var token = (await tokens.GetAsync()).AccessToken;

    // The lead. Replace these with your own details while you try it.
    var lead = new JsonObject
    {
        // Your system's name. Lowercase letters, numbers, "_", "." and "-", up to 64.
        ["source"] = "my_website",
        ["campaign"] = campaignKey,
        // Who they are. An email, a phone number or your own reference is needed
        // to recognise the same person next time.
        ["identity"] = new JsonObject
        {
            ["first_name"] = "YOUR_FIRST_NAME",
            ["last_name"] = "YOUR_LAST_NAME",
            ["email"] = "you@example.com",
            ["external_id"] = "your-ref-0001", // your own reference, if you have one
        },
        // Only what the person agreed to. Leave a channel out if they didn't.
        ["consent"] = new JsonObject
        {
            ["service"] = new JsonObject { ["email"] = true },
            ["method"] = "web_form",
            ["given_at"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
        },
        // Where they came from. Subcontext reads the utm tags from page_url.
        ["provenance"] = new JsonObject
        {
            ["page_url"] = "https://www.example.com/contact?utm_source=newsletter",
            ["referrer"] = "https://www.example.com/",
        },
        // Anything else, as free text. Read by Subcontext's AI to find details.
        ["text"] = "Asked about cover for a new mortgage.",
    };

    // One key per lead. Keep it with your own record of the lead and send the
    // same key again if you retry, so Subcontext knows it's the same lead.
    var idempotencyKey = Guid.NewGuid().ToString();
    Console.WriteLine($"Got a token. Sending the lead with Idempotency-Key {idempotencyKey}");

    // Tries once more, with the same key, if the connection fails or
    // Subcontext has a problem on its side (a 5xx status).
    HttpResponseMessage? response = null;
    for (var attempt = 1; attempt <= 2; attempt++)
    {
        var request = new HttpRequestMessage(HttpMethod.Post, baseUrl + "/api/v1/crm/ingests")
        {
            Content = new StringContent(lead.ToJsonString(), Encoding.UTF8, "application/json"),
        };
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        request.Headers.Add("Idempotency-Key", idempotencyKey);
        try
        {
            response = await http.SendAsync(request);
            if ((int)response.StatusCode < 500) break;
        }
        catch (HttpRequestException) when (attempt < 2) { }
    }

    var body = await Json.Read(response!);
    if (response!.StatusCode != HttpStatusCode.Accepted)
    {
        // Errors look like { "error": { "code": "...", "message": "...", "fields": { ... } } }.
        // A 401 or a 403 can have no body. See https://subcontext.com/developers/oauth/reference#errors-and-fixes.
        var error = body?["error"];
        Console.Error.WriteLine($"The lead was not accepted ({(int)response.StatusCode}): {error?["code"]} {error?["message"]}");
        return 1;
    }

    // 202 means Subcontext has stored the lead. It is processed in the
    // background, and the Location header is where to ask what became of it.
    var ingest = body!["data"]!;
    var duplicate = ingest["duplicate"]!.GetValue<bool>();
    Console.WriteLine("Accepted." + (duplicate ? " (Subcontext already had this one: no second lead was made.)" : ""));
    Console.WriteLine($"  id: {ingest["id"]}");
    Console.WriteLine($"  status: {ingest["status"]} - {ingest["explanation"]}");

    var location = response.Headers.Location;
    for (var check = 0; check < 5 && location != null; check++)
    {
        await Task.Delay(2000);
        var poll = new HttpRequestMessage(HttpMethod.Get, new Uri(new Uri(baseUrl), location));
        poll.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        var pollResponse = await http.SendAsync(poll);
        if (!pollResponse.IsSuccessStatusCode) break;
        var now = (await Json.Read(pollResponse))!["data"]!;
        Console.WriteLine($"  status now: {now["status"]} - {now["explanation"]}");
        if (now["status"]!.GetValue<string>() is not ("received" or "resolving" or "extracting")) break;
    }
    return 0;
}

// token-server is a small endpoint that gives your pages { access_token, expires_in }.
// On a real website, add the same MapGet to your own app at /subcontext-token,
// on the same website as the page, and the CORS lines aren't needed.
async Task<int> TokenServer(string[] commandLine)
{
    var allowedOrigin = Environment.GetEnvironmentVariable("ALLOWED_ORIGIN") ?? "http://localhost:4100";
    var port = Environment.GetEnvironmentVariable("PORT") ?? "8081";

    // What the quote form needs. Never give a browser token the crm scope.
    // One token is shared by every visitor until it nearly runs out. Asking
    // Subcontext for a new one per visitor would soon hit the rate limit.
    var tokens = new SubcontextTokens(http, baseUrl, clientId, clientSecret, "read write");

    var builder = WebApplication.CreateBuilder(commandLine.Skip(1).ToArray());
    builder.WebHost.UseUrls($"http://localhost:{port}");
    builder.Services.AddCors(cors => cors.AddDefaultPolicy(policy =>
        policy.WithOrigins(allowedOrigin).WithMethods("GET", "POST"))); // only your website's pages may read the answer
    var app = builder.Build();
    app.UseCors();

    async Task<IResult> Handle(HttpRequest request)
    {
        // A page from another website gets nothing. This only stops browsers.
        // Anyone can still call this endpoint from a script, which is why a
        // browser token must only be able to do what a visitor may do anyway.
        var origin = request.Headers.Origin.ToString();
        if (origin.Length > 0 && origin != allowedOrigin)
            return Results.Json(new { error = "origin not allowed" }, statusCode: 403);

        try
        {
            var token = await tokens.GetAsync();
            request.HttpContext.Response.Headers.CacheControl = "no-store";
            return Results.Json(new
            {
                access_token = token.AccessToken,
                // Seconds left on this token, which may be less than two hours.
                expires_in = (int)(token.ExpiresAt - DateTimeOffset.UtcNow).TotalSeconds,
            });
        }
        catch (Exception error) when (error is TokenRequestException or HttpRequestException or TaskCanceledException)
        {
            app.Logger.LogError("{Message}", error.Message);
            return Results.Json(new { error = "could not get a token" }, statusCode: 502);
        }
    }

    app.MapMethods("/subcontext-token", ["GET", "POST"], Handle);

    Console.WriteLine($"Token server on http://localhost:{port}/subcontext-token for pages on {allowedOrigin}");
    await app.RunAsync();
    return 0;
}

static class Json
{
    // The response body as JSON, or null if it is empty or not JSON.
    public static async Task<JsonNode?> Read(HttpResponseMessage response)
    {
        var text = await response.Content.ReadAsStringAsync();
        try { return text.Length > 0 ? JsonNode.Parse(text) : null; }
        catch (JsonException) { return null; }
    }
}

// Gets tokens and keeps the current one in memory until shortly before it
// runs out. Safe to share across threads. Copy this class into your own code.
sealed class SubcontextTokens(HttpClient http, string baseUrl, string clientId, string clientSecret, string scope)
{
    // Ask for a new token this long before the current one runs out, so a
    // token never expires in the middle of a request.
    static readonly TimeSpan RenewEarly = TimeSpan.FromMinutes(5);

    readonly SemaphoreSlim gate = new(1, 1);
    CachedToken? current;

    public async Task<CachedToken> GetAsync()
    {
        var token = current;
        if (token != null && DateTimeOffset.UtcNow < token.ExpiresAt - RenewEarly) return token;

        await gate.WaitAsync();
        try
        {
            if (current != null && DateTimeOffset.UtcNow < current.ExpiresAt - RenewEarly) return current;
            current = await RequestAsync();
            return current;
        }
        finally
        {
            gate.Release();
        }
    }

    // Forget the cached token, for example after the API answers 401.
    public void Forget() => current = null;

    async Task<CachedToken> RequestAsync()
    {
        var form = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["grant_type"] = "client_credentials",
            ["client_id"] = clientId,
            ["client_secret"] = clientSecret,
            ["scope"] = scope,
            // What the token is for (the Subcontext API). Required.
            ["resource"] = baseUrl + "/api/v1",
        });
        using var response = await http.PostAsync(baseUrl + "/oauth/token", form);
        var body = await Json.Read(response);
        if (!response.IsSuccessStatusCode)
        {
            var details = body?["error_description"] ?? body?["error"];
            throw new TokenRequestException($"Token request failed ({(int)response.StatusCode}): {details?.ToString() ?? "no details"}");
        }
        return new CachedToken(
            body!["access_token"]!.GetValue<string>(),
            DateTimeOffset.UtcNow.AddSeconds(body["expires_in"]!.GetValue<int>()));
    }
}

sealed record CachedToken(string AccessToken, DateTimeOffset ExpiresAt);

sealed class TokenRequestException(string message) : Exception(message);
