import { connectorFetch, connectorError, clampLimit } from "std::data/connector"

/** @module
  @summary Read Hacker News stories, items, profiles, and search results.
  ## Hacker News — front page, items, users, and search

  Read [Hacker News](https://github.com/HackerNews/API) via its official Firebase API (ranked story
  lists, items, and user profiles) and its [Algolia search](https://hn.algolia.com/api) index
  (keyword full-text search). Use this connector to see what the tech community is discussing right
  now (`hnStories`) or to find HN threads about a topic (`hnSearch`).

  No API key required. `hnStories` fetches a list of item IDs and then one request per item to
  hydrate it, so a large `limit` means many requests (it is capped at 100).

  ### Usage

  ```ts
  import { hnStories, hnSearch } from "std::data/tech/hackernews"

  node main() {
    const front = hnStories("top", 10) catch []
    for (s in front) {
      print("${s.score}  ${s.title}  (${s.by})")
    }
    const hits = hnSearch("rust async", "recent") catch []
    for (h in hits) { print(h.title) }
  }
  ```
*/

effect std::hackernews { op: string, query: string }

static const HN_BASE = "https://hacker-news.firebaseio.com/v0"
static const HN_DOMAINS = ["hacker-news.firebaseio.com"]
static const HN_ALGOLIA_BASE = "https://hn.algolia.com/api/v1"
static const HN_ALGOLIA_DOMAINS = ["hn.algolia.com"]

// Friendly list names, and the Firebase filename each maps to (parallel arrays).
static const HN_LISTS = ["top", "new", "best", "ask", "show", "job"]
static const HN_LIST_FILES = ["topstories", "newstories", "beststories", "askstories", "showstories", "jobstories"]

// Friendly sort names, and the Algolia endpoint each maps to (parallel arrays).
static const HN_SORTS = ["relevance", "recent"]
static const HN_SORT_ENDPOINTS = ["search", "search_by_date"]

/** A Hacker News story from a list or search, with `time` in Unix seconds. */
export type Story = {
  id: number;
  title: string;
  url: string;
  by: string;
  score: number;
  time: number;
  descendants: number;
  type: string
}

/** A full Hacker News item — story, comment, job, or poll. `kids` are direct child comment ids. */
export type Item = {
  id: number;
  type: string;
  by: string;
  time: number;
  title: string;
  url: string;
  text: string;
  score: number;
  descendants: number;
  parent: number | null;
  kids: number[]
}

/** A Hacker News user profile. `submitted` are the ids of items the user posted. */
export type User = {
  id: string;
  created: number;
  karma: number;
  about: string;
  submitted: number[]
}

// Validate a friendly list name and map it to its Firebase filename. Unknown → failure.
def parseStoryList(list: string): Result<string> {
  const idx = HN_LISTS.indexOf(list)
  if (idx < 0) {
    return failure("unknown list '${list}'; valid: ${HN_LISTS.join(", ")}")
  }
  return success(HN_LIST_FILES[idx])
}

// Validate a friendly sort name and map it to its Algolia endpoint. Unknown → failure.
def parseSort(sort: string): Result<string> {
  const idx = HN_SORTS.indexOf(sort)
  if (idx < 0) {
    return failure("unknown sort '${sort}'; valid: ${HN_SORTS.join(", ")}")
  }
  return success(HN_SORT_ENDPOINTS[idx])
}

// The first n ids (n clamped to ≥ 0; slice naturally clamps the high end).
def takeFirst(ids: number[], n: number): number[] {
  if (n <= 0) {
    return []
  }
  return ids.slice(0, n)
}

// Build a Firebase story-list path (file already mapped by parseStoryList).
def buildStoryListPath(file: string): string {
  return "/${file}.json"
}

// Build a Firebase item path.
def buildItemPath(id: number): string {
  return "/item/${id}.json"
}

// Build a Firebase user path.
def buildUserPath(username: string): string {
  return "/user/${encodeURIComponent(username)}.json"
}

// Build an Algolia search path (endpoint already mapped by parseSort; limit clamped to 50).
def buildSearchPath(endpoint: string, query: string, tags: string, limit: number): string {
  const q = encodeURIComponent(query)
  const t = encodeURIComponent(tags)
  const n = clampLimit(limit, 50)
  return "/${endpoint}?query=${q}&tags=${t}&hitsPerPage=${n}"
}

// Reshape a Firebase item into a Story (front-page/list shape).
def parseStory(raw: any): Story {
  const r: any = raw ?? {}
  return {
    id: r.id ?? 0,
    title: r.title ?? "",
    url: r.url ?? "",
    by: r.by ?? "",
    score: r.score ?? 0,
    time: r.time ?? 0,
    descendants: r.descendants ?? 0,
    type: r.type ?? "story"
  }
}

// Reshape a Firebase item into the full Item (story/comment/job/poll).
def parseItem(raw: any): Item {
  const r: any = raw ?? {}
  return {
    id: r.id ?? 0,
    type: r.type ?? "",
    by: r.by ?? "",
    time: r.time ?? 0,
    title: r.title ?? "",
    url: r.url ?? "",
    text: r.text ?? "",
    score: r.score ?? 0,
    descendants: r.descendants ?? 0,
    parent: r.parent ?? null,
    kids: r.kids ?? []
  }
}

// Reshape a Firebase user into a User.
def parseUser(raw: any): User {
  const r: any = raw ?? {}
  return {
    id: r.id ?? "",
    created: r.created ?? 0,
    karma: r.karma ?? 0,
    about: r.about ?? "",
    submitted: r.submitted ?? []
  }
}

// Reshape an Algolia search body into Story[]. Hits map onto Story (objectID→id, author→by,
// points→score, num_comments→descendants, created_at_i→time).
def parseAlgoliaHits(raw: any): Story[] {
  const r: any = raw ?? {}
  const hits = r.hits ?? []
  return map(hits) as h {
    const hit: any = h ?? {}
    return {
      id: Number(hit.objectID ?? hit.story_id ?? 0),
      title: hit.title ?? "",
      url: hit.url ?? "",
      by: hit.author ?? "",
      score: hit.points ?? 0,
      time: hit.created_at_i ?? 0,
      descendants: hit.num_comments ?? 0,
      type: "story"
    }
  }
}

// Turn a fetch Result into an Item Result; a null body (unknown id) → failure.
def itemFinalize(fetchResult: any): Result<Item> {
  return match (fetchResult) {
    success(body) => {
      const b: any = body ?? null
      if (b == null) {
        return failure("Hacker News returned no item")
      }
      return success(parseItem(b))
    }
    failure(err) => failure(connectorError("Hacker News", err))
  }
}

// Turn a fetch Result into a User Result; a null body (unknown user) → failure.
def userFinalize(fetchResult: any): Result<User> {
  return match (fetchResult) {
    success(body) => {
      const b: any = body ?? null
      if (b == null) {
        return failure("Hacker News returned no user")
      }
      return success(parseUser(b))
    }
    failure(err) => failure(connectorError("Hacker News", err))
  }
}

// Turn an Algolia fetch Result into a Story[] Result.
def searchFinalize(fetchResult: any): Result<Story[]> {
  return match (fetchResult) {
    success(body) => success(parseAlgoliaHits(body))
    failure(err) => failure(connectorError("Hacker News", err))
  }
}

// Hydrate the first `limit` story ids into Story records via one item fetch each. Items that fail
// to fetch are skipped.
def hydrateStories(ids: number[], limit: number): Story[] raises <std::http::fetchJSON> {
  let stories: Story[] = []
  for (id in takeFirst(ids, limit)) {
    const itemResult = connectorFetch(HN_BASE, HN_DOMAINS, buildItemPath(id)) with approve
    if (itemResult is success(body)) {
      stories.push(parseStory(body))
    }
  }
  return stories
}

// Turn the id-list fetch Result into hydrated Story[] (matched here, in a helper taking `any`, so it
// narrows correctly even though hnStories calls it after `return interrupt`).
def storiesFromFetch(idsResult: any, limit: number): Result<Story[]> raises <std::http::fetchJSON> {
  return match (idsResult) {
    success(body) => success(hydrateStories(body ?? [], limit))
    failure(err) => failure(connectorError("Hacker News", err))
  }
}

/**
  This requests the ranked ID list and then one item per requested story.
  Use a small limit to reduce requests. Items whose fetch fails are skipped.
*/
@capabilities(network)
export def hnStories(list: string = "top", limit: number = 30): Result<Story[]> raises <std::hackernews, std::http::fetchJSON> {
  """
  Read stories from a ranked Hacker News list.

  @param list - Use "top", "new", "best", "ask", "show", or "job".
  @param limit - The maximum stories to fetch, clamped to the range zero through 100.
  """
  const listResult = parseStoryList(list)
  if (isFailure(listResult)) {
    return listResult
  }
  const file = listResult.value
  return interrupt std::hackernews("Fetch this Hacker News story list?", { op: "stories", query: list })
  const idsResult = connectorFetch(HN_BASE, HN_DOMAINS, buildStoryListPath(file)) with approve
  return storiesFromFetch(idsResult, clampLimit(limit, 100))
}

@capabilities(network)
export def hnItem(id: number): Result<Item> raises <std::hackernews, std::http::fetchJSON> {
  """
  Read a Hacker News story, comment, job, or poll, including its child IDs.

  @param id - A Hacker News item ID from a story listing, search, or parent item.
  """
  return interrupt std::hackernews("Fetch this Hacker News item?", { op: "item", query: "${id}" })
  const result = connectorFetch(HN_BASE, HN_DOMAINS, buildItemPath(id)) with approve
  return itemFinalize(result)
}

@capabilities(network)
export def hnUser(username: string): Result<User> raises <std::hackernews, std::http::fetchJSON> {
  """
  Read a Hacker News user's public profile and submitted item IDs.

  @param username - The case-sensitive Hacker News username.
  """
  return interrupt std::hackernews("Fetch this Hacker News user?", { op: "user", query: username })
  const result = connectorFetch(HN_BASE, HN_DOMAINS, buildUserPath(username)) with approve
  return userFinalize(result)
}

@capabilities(network)
export def hnSearch(query: string, sort: string = "relevance", tags: string = "story", limit: number = 20): Result<Story[]> raises <std::hackernews, std::http::fetchJSON> {
  """
  Search Hacker News through its Algolia index.

  @param sort - Order results by "relevance" or "recent".
  @param tags - Algolia tag filters, such as "story", "comment", "ask_hn", or "show_hn".
  @param limit - The maximum results, clamped to the range zero through 50.
  """
  const sortResult = parseSort(sort)
  if (isFailure(sortResult)) {
    return sortResult
  }
  const endpoint = sortResult.value
  return interrupt std::hackernews("Search Hacker News for this query?", { op: "search", query: query })
  const result = connectorFetch(HN_ALGOLIA_BASE, HN_ALGOLIA_DOMAINS, buildSearchPath(endpoint, query, tags, limit)) with approve
  return searchFinalize(result)
}
