//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// You should have received a copy of the GNU Affero General Public License
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.

import gleam/erlang/process
import gleam/http/request
import gleam/httpc
import gleam/option.{type Option}
import wisp.{type Response}

pub const product_hunt_featured_url = "https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light"

pub const product_hunt_top_post_url = "https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1057558&theme=light&period=daily&t=1767529639613"

const stale_after_ms = 300_000

const receive_timeout_ms = 5000

const fetch_timeout_ms = 4500

pub opaque type Cache {
  Cache(subject: process.Subject(ServerMessage))
}

type ServerMessage {
  Get(process.Subject(Option(String)))
  RefreshDone(fetched_at: Int, svg: Option(String))
}

type CacheEntry {
  CacheEntry(svg: String, fetched_at: Int)
}

type State {
  State(cache: Option(CacheEntry), is_refreshing: Bool)
}

pub fn start_cache(url: String) -> Cache {
  let started = process.new_subject()
  let _ = process.spawn_unlinked(fn() { run(started, url) })

  let assert Ok(subject) = process.receive(started, within: 1000)
  Cache(subject: subject)
}

fn run(started: process.Subject(process.Subject(ServerMessage)), url: String) {
  let subject = process.new_subject()
  process.send(started, subject)

  let initial = State(cache: option.None, is_refreshing: False)
  loop(subject, url, initial)
}

fn loop(subject: process.Subject(ServerMessage), url: String, state: State) {
  let new_state = case process.receive(subject, within: stale_after_ms) {
    Ok(Get(reply_to)) -> handle_get(subject, reply_to, url, state)

    Ok(RefreshDone(fetched_at, svg)) ->
      handle_refresh_done(fetched_at, svg, state)

    Error(_) -> maybe_refresh_in_background(subject, url, state)
  }

  loop(subject, url, new_state)
}

fn handle_get(
  subject: process.Subject(ServerMessage),
  reply_to: process.Subject(Option(String)),
  url: String,
  state: State,
) -> State {
  let now = monotonic_time_ms()

  case state.cache {
    option.None -> {
      let svg = fetch_badge_svg(url)
      process.send(reply_to, svg)

      let new_cache = case svg {
        option.Some(content) ->
          option.Some(CacheEntry(svg: content, fetched_at: now))
        option.None -> option.None
      }

      State(cache: new_cache, is_refreshing: False)
    }

    option.Some(entry) -> {
      let is_stale = now - entry.fetched_at > stale_after_ms

      process.send(reply_to, option.Some(entry.svg))

      case is_stale && !state.is_refreshing {
        True -> {
          spawn_refresh(subject, url)
          State(..state, is_refreshing: True)
        }
        False -> state
      }
    }
  }
}

fn handle_refresh_done(
  fetched_at: Int,
  svg: Option(String),
  state: State,
) -> State {
  let new_cache = case svg {
    option.Some(content) ->
      option.Some(CacheEntry(svg: content, fetched_at: fetched_at))
    option.None -> state.cache
  }

  State(cache: new_cache, is_refreshing: False)
}

fn maybe_refresh_in_background(
  subject: process.Subject(ServerMessage),
  url: String,
  state: State,
) -> State {
  let now = monotonic_time_ms()

  case state.cache, state.is_refreshing {
    option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> {
      spawn_refresh(subject, url)
      State(..state, is_refreshing: True)
    }

    _, _ -> state
  }
}

fn spawn_refresh(subject: process.Subject(ServerMessage), url: String) {
  let _ =
    process.spawn_unlinked(fn() {
      let fetched_at = monotonic_time_ms()
      let svg = fetch_badge_svg(url)
      process.send(subject, RefreshDone(fetched_at, svg))
    })

  Nil
}

pub fn get_badge(cache: Cache) -> Option(String) {
  let reply_to = process.new_subject()
  process.send(cache.subject, Get(reply_to))

  case process.receive(reply_to, within: receive_timeout_ms) {
    Ok(svg) -> svg
    Error(_) -> option.None
  }
}

pub fn product_hunt(cache: Cache) -> Response {
  case get_badge(cache) {
    option.Some(content) -> {
      wisp.response(200)
      |> wisp.set_header("content-type", "image/svg+xml")
      |> wisp.set_header(
        "cache-control",
        "public, max-age=300, stale-while-revalidate=600",
      )
      |> wisp.set_header("vary", "Accept")
      |> wisp.string_body(content)
    }

    option.None -> {
      wisp.response(503)
      |> wisp.set_header("content-type", "text/plain")
      |> wisp.set_header("retry-after", "60")
      |> wisp.string_body("Badge temporarily unavailable")
    }
  }
}

fn fetch_badge_svg(url: String) -> Option(String) {
  let assert Ok(req0) = request.to(url)
  let req =
    req0
    |> request.prepend_header("accept", "image/svg+xml")
    |> request.prepend_header("user-agent", "FluxerMarketing/1.0")

  let config =
    httpc.configure()
    |> httpc.timeout(fetch_timeout_ms)

  case httpc.dispatch(config, req) {
    Ok(resp) if resp.status >= 200 && resp.status < 300 ->
      option.Some(resp.body)
    _ -> option.None
  }
}

type TimeUnit {
  Millisecond
}

@external(erlang, "erlang", "monotonic_time")
fn erlang_monotonic_time(unit: TimeUnit) -> Int

fn monotonic_time_ms() -> Int {
  erlang_monotonic_time(Millisecond)
}
