/**
 * Client transport for the app backend (docs/backend-plan.md §3.2 / §3.3). The compiler replaces
 * every `*.server.ts` module in the client bundle with a stub whose exports are built by the three
 * globals below — user code never calls them; it imports the server function and calls it.
 *
 *   export const listNotes = __rpc("<serverUrl>", "notes.server.ts#listNotes")
 *   export const chat      = __channel("<serverUrl>", "chat.server.ts#chat")
 *   export const db        = __serverOnly("schema.server.ts#db")
 *
 * Wire (implemented by the runner):
 *   POST <serverUrl>/api/<id>   body {"args":[…]}, `authorization: Bearer <session>` when known
 *   → 200 {"ok":true,"result":…,"session"?:"…"} | {"ok":false,"status":n,"message":"…","session"?:"…"}
 *   WS   <serverUrl>/ws  frames: → {t:"hello",session} {t:"sub",id,ch,topic} {t:"unsub",id}
 *                                ← {t:"session",session} {t:"ok",id} {t:"err",id,status,message} {t:"ev",ch,topic,event,data}
 *   (`ev` is addressed by channel + topic, not by subscription id, so the server can fan one publish out
 *   to every socket of a topic natively; the client delivers it to each matching subscription.)
 *
 * The session token is transport-owned (cookie-like): stored under `lecodes.session:<serverUrl>` in
 * localStorage, sent on every call and on the socket, replaced whenever a response carries `session`.
 * User code never sees it — "am I logged in" is an endpoint (`me()`), see the plan.
 */

import { fetch } from "./fetch"
import { localStorage } from "./storage"
import { WebSocket } from "./net"

/** Rejection value of a failed endpoint call: `status` mirrors the server's `ApiError`
 *  (0 = the request itself failed: no network / no server). */
export class RpcError extends Error {
  readonly status: number
  constructor(status: number, message: string) {
    super(message)
    this.name = "RpcError"
    this.status = status
  }
}

// ───────────────────────────── session token ─────────────────────────────

const sessionKey = (serverUrl: string) => `lecodes.session:${serverUrl}`
const getSession = (serverUrl: string): string | null => {
  try { return localStorage.getItem(sessionKey(serverUrl)) } catch { return null }
}
const setSession = (serverUrl: string, token: string | null) => {
  try {
    if (token) localStorage.setItem(sessionKey(serverUrl), token)
    else localStorage.removeItem(sessionKey(serverUrl))
  } catch { /* storage-less host: the session lives for this run only */ }
  sockets.get(serverUrl)?.onSessionChanged(token)
}

// ───────────────────────────── endpoints ─────────────────────────────

const noServer = (id: string) => new RpcError(0, `Endpoint ${id}: the project has no deployed server (compile with serverUrl)`)

/** Build the client proxy of endpoint `id`. Positional args travel as a JSON array. */
export const __rpc = (serverUrl: string, id: string) => {
  const call = async (...args: unknown[]): Promise<any> => {
    if (!serverUrl) throw noServer(id)
    const headers: Record<string, string> = { "content-type": "application/json" }
    const session = getSession(serverUrl)
    if (session) headers.authorization = `Bearer ${session}`
    let res
    try {
      res = await fetch(`${serverUrl}/api/${encodeURIComponent(id)}`, { method: "POST", headers, body: JSON.stringify({ args }) })
    } catch (e) {
      throw new RpcError(0, `Endpoint ${id}: request failed (${(e as Error)?.message ?? e})`)
    }
    let body: any
    try { body = res.json() } catch { body = null }
    res.dispose()
    if (body && typeof body === "object" && "session" in body) setSession(serverUrl, body.session ?? null)
    if (body && body.ok === true) return body.result
    if (body && body.ok === false) throw new RpcError(body.status ?? res.status, body.message ?? "")
    throw new RpcError(res.status, `Endpoint ${id}: unexpected response (HTTP ${res.status})`)
  }
  return call
}

/** A server-only export (db, models, helpers) referenced from client code: any use throws. */
export const __serverOnly = (id: string): any =>
  new Proxy(function () {} as any, {
    get: (_t, prop) => { if (prop === "then") return undefined; throw new RpcError(0, `${id} is server-only — it can't be used in client code`) },
    apply: () => { throw new RpcError(0, `${id} is server-only — it can't be used in client code`) },
  })

// ───────────────────────────── channels ─────────────────────────────

type Handlers = Record<string, ((payload: any) => void) | undefined> & {
  /** The socket dropped and came back: subscriptions were re-sent, but events in between are lost — refetch. */
  reconnect?: () => void
  /** The subscription was refused (`onJoin` threw) or the socket can't connect. */
  error?: (e: RpcError) => void
}
type Sub = { id: number, ch: string, topic: string, handlers: Handlers, ready: boolean }

const BACKOFF_MS = [1000, 2000, 5000, 10000, 30000]

/** One multiplexed socket per server URL, shared by every channel of the app. */
class ChannelSocket {
  private ws: WebSocket | null = null
  private open = false
  private subs = new Map<number, Sub>()
  private nextId = 1
  private attempts = 0
  private timer: ReturnType<typeof setTimeout> | null = null
  private closedByUs = false
  private everOpened = false
  private serverUrl: string

  constructor(serverUrl: string) { this.serverUrl = serverUrl }

  subscribe(ch: string, topic: string, handlers: Handlers): { close(): void } {
    const sub: Sub = { id: this.nextId++, ch, topic, handlers, ready: false }
    this.subs.set(sub.id, sub)
    if (this.open) this.send({ t: "sub", id: sub.id, ch, topic })
    else this.connect()
    return {
      close: () => {
        if (!this.subs.delete(sub.id)) return
        if (this.open) this.send({ t: "unsub", id: sub.id })
        if (this.subs.size === 0) this.shutdown()
      },
    }
  }

  onSessionChanged(token: string | null) {
    if (this.open) this.send({ t: "hello", session: token })
  }

  private connect() {
    if (this.ws || this.timer !== null) return
    this.closedByUs = false
    const url = this.serverUrl.replace(/^http/, "ws") + "/ws"
    const session = getSession(this.serverUrl)
    const ws = new WebSocket(url, session ? { authorization: `Bearer ${session}` } : undefined)
    this.ws = ws
    ws.addEventListener("open", () => {
      this.open = true
      this.attempts = 0
      this.send({ t: "hello", session: getSession(this.serverUrl) })
      const reconnected = this.everOpened
      this.everOpened = true
      for (const sub of this.subs.values()) {
        sub.ready = false
        this.send({ t: "sub", id: sub.id, ch: sub.ch, topic: sub.topic })
        if (reconnected) sub.handlers.reconnect?.()
      }
    })
    ws.addEventListener("message", (data) => {
      if (typeof data !== "string") return
      let msg: any
      try { msg = JSON.parse(data) } catch { return }
      this.onFrame(msg)
    })
    const onDown = () => {
      if (this.ws !== ws) return
      this.ws = null
      this.open = false
      if (this.closedByUs || this.subs.size === 0) return
      const delay = BACKOFF_MS[Math.min(this.attempts++, BACKOFF_MS.length - 1)]
      this.timer = setTimeout(() => { this.timer = null; this.connect() }, delay)
    }
    ws.addEventListener("close", onDown)
    ws.addEventListener("error", onDown)
  }

  private onFrame(msg: any) {
    switch (msg?.t) {
      case "session": setSession(this.serverUrl, msg.session ?? null); break
      case "ok": { const s = this.subs.get(msg.id); if (s) s.ready = true; break }
      case "err": {
        const s = this.subs.get(msg.id)
        if (!s) break
        this.subs.delete(msg.id)
        s.handlers.error?.(new RpcError(msg.status ?? 0, msg.message ?? "subscription refused"))
        break
      }
      case "ev": {
        for (const s of this.subs.values()) {
          if (s.ch === msg.ch && s.topic === msg.topic) s.handlers[msg.event]?.(msg.data)
        }
        break
      }
    }
  }

  private send(frame: unknown) { this.ws?.send(JSON.stringify(frame)) }

  private shutdown() {
    this.closedByUs = true
    if (this.timer !== null) { clearTimeout(this.timer); this.timer = null }
    this.ws?.close()
    this.ws = null
    this.open = false
    sockets.delete(this.serverUrl)
  }
}

const sockets = new Map<string, ChannelSocket>()
const socketFor = (serverUrl: string) => {
  let s = sockets.get(serverUrl)
  if (!s) { s = new ChannelSocket(serverUrl); sockets.set(serverUrl, s) }
  return s
}

export interface ClientChannel<E extends Record<string, unknown> = Record<string, unknown>> {
  /** Subscribe to `topic`; handlers are keyed by event name (+ `reconnect`/`error`). Returns `{ close }`. */
  subscribe(topic: string, handlers: { [K in keyof E]?: (payload: E[K]) => void } & { reconnect?: () => void, error?: (e: RpcError) => void }): { close(): void }
}

/** Build the client proxy of channel `id`. */
export const __channel = (serverUrl: string, id: string): ClientChannel<any> => ({
  subscribe(topic, handlers) {
    if (!serverUrl) throw noServer(id)
    return socketFor(serverUrl).subscribe(id, topic, handlers as Handlers)
  },
})
