// XFTP HTTP/2 client -- Simplex.FileTransfer.Client
//
// Connects to XFTP server via HTTP/2, performs web handshake,
// sends authenticated commands, receives responses.
//
// Uses node:http2 in Node.js (tests), fetch() in browsers.

import {
  encodeAuthTransmission, encodeTransmission, decodeTransmission,
  XFTP_BLOCK_SIZE, initialXFTPVersion, currentXFTPVersion
} from "./protocol/transmission.js"
import {
  encodeClientHello, encodeClientHandshake, decodeServerHandshake,
  compatibleVRange
} from "./protocol/handshake.js"
import {verifyIdentityProof} from "./crypto/identity.js"
import {generateX25519KeyPair, encodePubKeyX25519, dh} from "./crypto/keys.js"
import {
  encodeFNEW, encodeFADD, encodeFPUT, encodeFGET, encodeFDEL, encodePING,
  decodeResponse, type FileResponse, type FileInfo, type XFTPErrorType
} from "./protocol/commands.js"
import {decryptReceivedChunk} from "./download.js"
import type {XFTPServer} from "./protocol/address.js"
import {formatXFTPServer} from "./protocol/address.js"
import {concatBytes} from "./protocol/encoding.js"
import {blockUnpad} from "./protocol/transmission.js"

// -- Error types

export class XFTPRetriableError extends Error {
  constructor(public readonly errorType: string) {
    super(humanReadableMessage(errorType))
    this.name = "XFTPRetriableError"
  }
}

export class XFTPPermanentError extends Error {
  constructor(public readonly errorType: string, message: string) {
    super(message)
    this.name = "XFTPPermanentError"
  }
}

export function isRetriable(e: unknown): boolean {
  if (e instanceof XFTPRetriableError) return true
  if (e instanceof XFTPPermanentError) return false
  if (e instanceof TypeError) return true  // fetch network error
  if (e instanceof Error && e.name === "AbortError") return true  // timeout
  return false
}

export function categorizeError(e: unknown): Error {
  if (e instanceof XFTPRetriableError || e instanceof XFTPPermanentError) return e
  if (e instanceof TypeError) return new XFTPRetriableError("NETWORK")
  if (e instanceof Error && e.name === "AbortError") return new XFTPRetriableError("TIMEOUT")
  return e instanceof Error ? e : new Error(String(e))
}

export function humanReadableMessage(errorType: string | XFTPErrorType): string {
  const t = typeof errorType === "string" ? errorType : errorType.type
  switch (t) {
    case "SESSION": return "Session expired, reconnecting..."
    case "HANDSHAKE": return "Connection interrupted, reconnecting..."
    case "NETWORK": return "Network error, retrying..."
    case "TIMEOUT": return "Server timeout, retrying..."
    case "AUTH": return "File is invalid, expired, or has been removed"
    case "NO_FILE": return "File not found — it may have expired"
    case "SIZE": return "File size exceeds server limit"
    case "QUOTA": return "Server storage quota exceeded"
    case "BLOCKED": return "File has been blocked by server"
    case "DIGEST": return "File integrity check failed"
    case "INTERNAL": return "Server internal error"
    case "CMD": return "Protocol error"
    default: return "Server error: " + t
  }
}

// -- Types

export interface XFTPClient {
  baseUrl: string
  sessionId: Uint8Array
  xftpVersion: number
  transport: Transport
}

export interface TransportConfig {
  timeoutMs: number  // default 30000 (30s), lower for tests
}

const DEFAULT_TRANSPORT_CONFIG: TransportConfig = {timeoutMs: 30000}

interface Transport {
  post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array>
  close(): void
}

// -- Transport implementations

const isNode = typeof (globalThis as any).process !== "undefined" && (globalThis as any).process.versions?.node

// In development mode, use HTTP proxy to avoid self-signed cert issues in browser
// __XFTP_PROXY_PORT__ is injected by vite build (null in production)
declare const __XFTP_PROXY_PORT__: string | null

async function createTransport(baseUrl: string, config: TransportConfig): Promise<Transport> {
  if (isNode) {
    return createNodeTransport(baseUrl, config)
  } else {
    return createBrowserTransport(baseUrl, config)
  }
}

async function createNodeTransport(baseUrl: string, config: TransportConfig): Promise<Transport> {
  // @ts-ignore node:http2 unavailable in browser tsconfig
  const http2: any = await import("node:http2")
  const session = http2.connect(baseUrl, {rejectUnauthorized: false})
  return {
    async post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array> {
      return new Promise((resolve, reject) => {
        const req = session.request({":method": "POST", ":path": "/", ...headers})
        req.setTimeout(config.timeoutMs, () => {
          req.close()
          reject(Object.assign(new Error("Request timeout"), {name: "AbortError"}))
        })
        const chunks: any[] = []
        req.on("data", (chunk: any) => chunks.push(chunk))
        req.on("end", () => {
          const B = (globalThis as any).Buffer
          resolve(new Uint8Array(B.concat(chunks)))
        })
        req.on("error", reject)
        req.end(body)
      })
    },
    close() {
      session.close()
    }
  }
}

function createBrowserTransport(baseUrl: string, config: TransportConfig): Transport {
  // In dev mode, route through /xftp-proxy to avoid self-signed cert rejection
  // __XFTP_PROXY_PORT__ is 'proxy' in dev mode (uses relative path), null in production
  const effectiveUrl = typeof __XFTP_PROXY_PORT__ !== 'undefined' && __XFTP_PROXY_PORT__
    ? '/xftp-proxy'
    : baseUrl
  return {
    async post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array> {
      const controller = new AbortController()
      const timer = setTimeout(() => controller.abort(), config.timeoutMs)
      try {
        const resp = await fetch(effectiveUrl, {
          method: "POST",
          headers,
          body: body as any,
          signal: controller.signal
        })
        if (!resp.ok) {
          console.error('[XFTP] fetch %s failed: %d %s', effectiveUrl, resp.status, resp.statusText)
          throw new Error(`Server request failed: ${resp.status} ${resp.statusText}`)
        }
        return new Uint8Array(await resp.arrayBuffer())
      } finally {
        clearTimeout(timer)
      }
    },
    close() {}
  }
}

// -- Client agent (connection pool with Promise-based lock)

interface ServerConnection {
  client: Promise<XFTPClient>   // resolves to connected client; replaced on reconnect
  queue: Promise<void>          // tail of sequential command chain
}

export interface XFTPClientAgent {
  connections: Map<string, ServerConnection>
  /** @internal Injectable for testing — defaults to connectXFTP */
  _connectFn: (server: XFTPServer) => Promise<XFTPClient>
}

export function newXFTPAgent(): XFTPClientAgent {
  return {connections: new Map(), _connectFn: connectXFTP}
}

export function getXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
  const key = formatXFTPServer(server)
  let conn = agent.connections.get(key)
  if (!conn) {
    const p = agent._connectFn(server)
    conn = {client: p, queue: Promise.resolve()}
    agent.connections.set(key, conn)
    p.catch(() => {
      const cur = agent.connections.get(key)
      if (cur && cur.client === p) agent.connections.delete(key)
    })
  }
  return conn.client
}

export function reconnectClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
  const key = formatXFTPServer(server)
  const old = agent.connections.get(key)
  old?.client.then(c => c.transport.close(), () => {})
  const p = agent._connectFn(server)
  const conn: ServerConnection = {client: p, queue: old?.queue ?? Promise.resolve()}
  agent.connections.set(key, conn)
  p.catch(() => {
    const cur = agent.connections.get(key)
    if (cur && cur.client === p) agent.connections.delete(key)
  })
  return p
}

export function removeStaleConnection(
  agent: XFTPClientAgent, server: XFTPServer, failedP: Promise<XFTPClient>
): void {
  const key = formatXFTPServer(server)
  const conn = agent.connections.get(key)
  if (conn && conn.client === failedP) {
    agent.connections.delete(key)
    failedP.then(c => c.transport.close(), () => {})
  }
}

export function closeXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): void {
  const key = formatXFTPServer(server)
  const conn = agent.connections.get(key)
  if (conn) {
    agent.connections.delete(key)
    conn.client.then(c => c.transport.close(), () => {})
  }
}

export function closeXFTPAgent(agent: XFTPClientAgent): void {
  for (const conn of agent.connections.values()) {
    conn.client.then(c => c.transport.close(), () => {})
  }
  agent.connections.clear()
}

// -- Connect + handshake

async function connectXFTP(server: XFTPServer, config?: Partial<TransportConfig>): Promise<XFTPClient> {
  const cfg: TransportConfig = {...DEFAULT_TRANSPORT_CONFIG, ...config}
  const baseUrl = "https://" + server.host + ":" + server.port
  const transport = await createTransport(baseUrl, cfg)

  try {
    // Step 1: send client hello with web challenge
    const challenge = new Uint8Array(32)
    crypto.getRandomValues(challenge)
    const clientHelloBytes = encodeClientHello({webChallenge: challenge})
    const shsBody = await transport.post(clientHelloBytes, {"xftp-web-hello": "1"})

    // Step 2: decode + verify server handshake
    const hs = decodeServerHandshake(shsBody)
    if (!hs.webIdentityProof) {
      console.error('[XFTP] Server did not provide web identity proof')
      throw new Error("Server did not provide web identity proof")
    }
    const idOk = verifyIdentityProof({
      certChainDer: hs.certChainDer,
      signedKeyDer: hs.signedKeyDer,
      sigBytes: hs.webIdentityProof,
      challenge,
      sessionId: hs.sessionId,
      keyHash: server.keyHash
    })
    if (!idOk) {
      console.error('[XFTP] Server identity verification failed')
      throw new Error("Server identity verification failed")
    }

    // Step 3: version negotiation
    const vr = compatibleVRange(hs.xftpVersionRange, {minVersion: initialXFTPVersion, maxVersion: currentXFTPVersion})
    if (!vr) {
      console.error('[XFTP] Incompatible server version: %o', hs.xftpVersionRange)
      throw new Error("Incompatible server version")
    }
    const xftpVersion = vr.maxVersion

    // Step 4: send client handshake
    const ack = await transport.post(encodeClientHandshake({xftpVersion, keyHash: server.keyHash}), {"xftp-handshake": "1"})
    if (ack.length !== 0) {
      console.error('[XFTP] Non-empty handshake ack (%d bytes)', ack.length)
      throw new Error("Server handshake failed")
    }

    return {baseUrl, sessionId: hs.sessionId, xftpVersion, transport}
  } catch (e) {
    console.error('[XFTP] Connection to %s failed:', baseUrl, e)
    transport.close()
    throw e
  }
}

// -- Send command (single attempt, no retry)

async function sendXFTPCommandOnce(
  client: XFTPClient,
  privateKey: Uint8Array,
  entityId: Uint8Array,
  cmdBytes: Uint8Array,
  chunkData?: Uint8Array
): Promise<{response: FileResponse, body: Uint8Array}> {
  const corrId = new Uint8Array(0)
  const block = encodeAuthTransmission(client.sessionId, corrId, entityId, cmdBytes, privateKey)
  const reqBody = chunkData ? concatBytes(block, chunkData) : block
  const fullResp = await client.transport.post(reqBody)
  if (fullResp.length < XFTP_BLOCK_SIZE) {
    console.error('[XFTP] Response too short: %d bytes (expected >= %d)', fullResp.length, XFTP_BLOCK_SIZE)
    throw new Error("Server response too short")
  }
  const respBlock = fullResp.subarray(0, XFTP_BLOCK_SIZE)
  const body = fullResp.subarray(XFTP_BLOCK_SIZE)
  // Detect padded error strings (HANDSHAKE, SESSION) before decodeTransmission
  const raw = blockUnpad(respBlock)
  if (raw.length < 20) {
    const text = new TextDecoder().decode(raw)
    if (/^[A-Z_]+$/.test(text)) {
      throw new XFTPRetriableError(text)
    }
  }
  const {command} = decodeTransmission(client.sessionId, respBlock)
  const response = decodeResponse(command)
  if (response.type === "FRErr") {
    const err = response.err
    if (err.type === "SESSION" || err.type === "HANDSHAKE") {
      throw new XFTPRetriableError(err.type)
    }
    throw new XFTPPermanentError(err.type, humanReadableMessage(err))
  }
  return {response, body}
}

// -- Send command (with retry + reconnect)

export async function sendXFTPCommand(
  agent: XFTPClientAgent,
  server: XFTPServer,
  privateKey: Uint8Array,
  entityId: Uint8Array,
  cmdBytes: Uint8Array,
  chunkData?: Uint8Array,
  maxRetries: number = 3
): Promise<{response: FileResponse, body: Uint8Array}> {
  let clientP = getXFTPServerClient(agent, server)
  let client = await clientP
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await sendXFTPCommandOnce(client, privateKey, entityId, cmdBytes, chunkData)
    } catch (e) {
      if (!isRetriable(e)) {
        throw categorizeError(e)
      }
      if (attempt === maxRetries) {
        removeStaleConnection(agent, server, clientP)
        throw categorizeError(e)
      }
      clientP = reconnectClient(agent, server)
      client = await clientP
    }
  }
  throw new Error("unreachable")
}

// -- Command wrappers

export async function createXFTPChunk(
  agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, file: FileInfo,
  rcvKeys: Uint8Array[], auth: Uint8Array | null = null
): Promise<{senderId: Uint8Array, recipientIds: Uint8Array[]}> {
  const {response} = await sendXFTPCommand(agent, server, spKey, new Uint8Array(0), encodeFNEW(file, rcvKeys, auth))
  if (response.type !== "FRSndIds") throw new Error("unexpected response: " + response.type)
  return {senderId: response.senderId, recipientIds: response.recipientIds}
}

export async function addXFTPRecipients(
  agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, fId: Uint8Array, rcvKeys: Uint8Array[]
): Promise<Uint8Array[]> {
  const {response} = await sendXFTPCommand(agent, server, spKey, fId, encodeFADD(rcvKeys))
  if (response.type !== "FRRcvIds") throw new Error("unexpected response: " + response.type)
  return response.recipientIds
}

export async function uploadXFTPChunk(
  agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, fId: Uint8Array, chunkData: Uint8Array
): Promise<void> {
  const {response} = await sendXFTPCommand(agent, server, spKey, fId, encodeFPUT(), chunkData)
  if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
}

export interface RawChunkResponse {
  dhSecret: Uint8Array
  nonce: Uint8Array
  body: Uint8Array
}

export async function downloadXFTPChunkRaw(
  agent: XFTPClientAgent, server: XFTPServer, rpKey: Uint8Array, fId: Uint8Array
): Promise<RawChunkResponse> {
  const {publicKey, privateKey} = generateX25519KeyPair()
  const cmd = encodeFGET(encodePubKeyX25519(publicKey))
  const {response, body} = await sendXFTPCommand(agent, server, rpKey, fId, cmd)
  if (response.type !== "FRFile") throw new Error("unexpected response: " + response.type)
  const dhSecret = dh(response.rcvDhKey, privateKey)
  return {dhSecret, nonce: response.nonce, body}
}

export async function downloadXFTPChunk(
  agent: XFTPClientAgent, server: XFTPServer, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
): Promise<Uint8Array> {
  const {dhSecret, nonce, body} = await downloadXFTPChunkRaw(agent, server, rpKey, fId)
  return decryptReceivedChunk(dhSecret, nonce, body, digest ?? null)
}

export async function deleteXFTPChunk(
  agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, sId: Uint8Array
): Promise<void> {
  const {response} = await sendXFTPCommand(agent, server, spKey, sId, encodeFDEL())
  if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
}

export async function pingXFTP(agent: XFTPClientAgent, server: XFTPServer): Promise<void> {
  const client = await getXFTPServerClient(agent, server)
  const corrId = new Uint8Array(0)
  const block = encodeTransmission(client.sessionId, corrId, new Uint8Array(0), encodePING())
  const fullResp = await client.transport.post(block)
  if (fullResp.length < XFTP_BLOCK_SIZE) throw new Error("pingXFTP: response too short")
  const {command} = decodeTransmission(client.sessionId, fullResp.subarray(0, XFTP_BLOCK_SIZE))
  const response = decodeResponse(command)
  if (response.type !== "FRPong") throw new Error("unexpected response: " + response.type)
}
