// dsh-local-403-fix
//
// Why this exists: official dsh pins privileged /api methods (settings.*,
// credentials.*, agentPreset.read/copy/remove/openDocument, host.pickDirectory,
// host.openPath, llm.discoverModels) to loopback. The connection plugin re-runs
// the browser-trust fence for them with an EMPTY trustedHosts list, so any
// reverse-proxied /api call to those methods returns 403 no matter what
// --trusted-host lists.
//
// This plugin registers an exact /api/<method> route for each privileged method
// and validates those requests against the same trustedHosts list the rest of
// /api uses. Exact routes win over the official prefix route, so these requests
// never reach the official empty-list check. It keeps the official connection
// plugin in place and adds no authentication of its own — trustedHosts is a
// DNS-rebinding fence, not an auth layer, exactly like upstream.
//
// The Host/Origin/Fetch-Metadata fence below mirrors the MIT-licensed
// @deepseek-ai/dsh-client-connection api-request-trust.ts (0.1.0-rc.6).
// toFetchHandler comes from the official @deepseek-ai/dsh-host-apiproxy
// package (DeepSeek official, github.com/deepseek-ai/deepseek-harness).

import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'

export const name = 'dsh-local-403-fix'
export const inject = ['webServer', 'connection']

const API_PATH = '/api'
const DEFAULT_MAX_REQUEST_BODY_BYTES = 160 * 1024 * 1024

// Official PRIVILEGED_METHODS from
// @deepseek-ai/dsh-client-connection src/index.ts (0.1.0-rc.6 / master).
const PRIVILEGED_METHODS = Object.freeze([
  'agentPreset.read',
  'agentPreset.copy',
  'agentPreset.openDocument',
  'agentPreset.remove',
  'host.pickDirectory',
  'host.openPath',
  'settings.describe',
  'settings.openDocument',
  'settings.update',
  'settings.replace',
  'settings.mutate',
  'credentials.describe',
  'credentials.set',
  'credentials.unset',
  'llm.discoverModels'
])

// ── browser-trust fence (mirrors api-request-trust.ts) ──────────────────────

function header(headers, name) {
  if (headers && typeof headers.get === 'function') {
    const value = headers.get(name)
    return value == null ? undefined : value
  }
  if (!headers) return undefined
  const value = headers[name]
  return typeof value === 'string' ? value : undefined
}

function parseAuthority(authority) {
  try {
    return new URL('http://' + authority)
  } catch (err) {
    return undefined
  }
}

function canonicalAuthority(entry, entryUrl) {
  const port = entryUrl.port !== '' ? entryUrl.port : new URL('https://' + entry).port
  return port === '' ? entryUrl.hostname : entryUrl.hostname + ':' + port
}

function isLoopbackHostname(hostname) {
  if (hostname === 'localhost' || hostname === '[::1]') return true
  const parts = hostname.split('.')
  if (parts.length !== 4 || parts[0] !== '127') return false
  for (let i = 0; i < parts.length; i++) {
    if (!/^\d{1,3}$/.test(parts[i])) return false
    if (Number(parts[i]) > 255) return false
  }
  return true
}

function isTrustedAuthority(hostUrl, trustedHosts) {
  if (!trustedHosts || !trustedHosts.length) return false
  for (let i = 0; i < trustedHosts.length; i++) {
    const entry = trustedHosts[i]
    const entryUrl = parseAuthority(entry)
    if (entryUrl === undefined) continue
    if (canonicalAuthority(entry, entryUrl) === entryUrl.hostname) {
      if (entryUrl.hostname === hostUrl.hostname) return true
    } else if (entryUrl.host === hostUrl.host) {
      return true
    }
  }
  return false
}

export function isTrustedApiRequest(request, trustedHosts) {
  if (!request) return false
  const host = header(request.headers, 'host')
  if (host === undefined) return false
  const hostUrl = parseAuthority(host)
  if (hostUrl === undefined) return false
  if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) {
    return false
  }
  if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
  const origin = header(request.headers, 'origin')
  if (origin === undefined) return true
  try {
    return new URL(origin).host === hostUrl.host
  } catch (err) {
    return false
  }
}

// ── plugin entry ────────────────────────────────────────────────────────────

export function apply(ctx, config) {
  const trustedHosts = config && config.trustedHosts ? config.trustedHosts : []
  const maxBytes =
    config && config.maxRequestBodyBytes
      ? config.maxRequestBodyBytes
      : DEFAULT_MAX_REQUEST_BODY_BYTES

  ctx.inject(['apiProxy'], function (apiCtx) {
    // Same shared fetch handler the official /api route uses: dispatches RPC
    // endpoints through the connection interceptor, falls back to the api
    // proxy carrier. We only bypass the official EMPTY-list privileged check
    // by registering exact routes that validate with trustedHosts instead.
    const fetchHandler = apiCtx.connection.createSharedFetchHandler(
      API_PATH,
      toFetchHandler(apiCtx.apiProxy)
    )

    for (let i = 0; i < PRIVILEGED_METHODS.length; i++) {
      const method = PRIVILEGED_METHODS[i]
      apiCtx.effect(function () {
        return apiCtx.webServer.register({
          kind: 'exact',
          path: API_PATH + '/' + method,
          handler: function (req, res) {
            if (!isTrustedApiRequest(req, trustedHosts)) {
              res.writeHead(403)
              res.end('forbidden')
              return
            }
            return bridge(req, res, fetchHandler, maxBytes)
          }
        })
      }, name + ': ' + API_PATH + '/' + method)
    }
  })
}

// ── node:http -> fetch bridge (mirrors http-bridge.ts) ──────────────────────

function closed(res, abort) {
  return Boolean((abort && abort.signal.aborted) || res.destroyed || res.writableEnded)
}

function waitWritable(res) {
  return new Promise(function (resolve) {
    if (res.destroyed || res.writableEnded) {
      resolve()
      return
    }
    function done() {
      res.off('drain', done)
      res.off('close', done)
      resolve()
    }
    res.once('drain', done)
    res.once('close', done)
  })
}

async function cancelBody(body) {
  if (!body || typeof body.cancel !== 'function') return
  try {
    await body.cancel()
  } catch (err) {}
}

async function bridge(req, res, apiHandler, maxRequestBodyBytes) {
  const abort = new AbortController()
  function onClose() {
    if (!res.writableEnded) abort.abort()
  }
  res.on('close', onClose)
  try {
    const declaredLength = req.headers['content-length']
    if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
      if (!res.headersSent && !closed(res, abort)) {
        res.writeHead(413, { connection: 'close' })
        res.end()
      }
      req.destroy()
      return
    }
    const chunks = []
    let received = 0
    for await (const chunk of req) {
      if (closed(res, abort)) return
      received += chunk.byteLength
      if (received > maxRequestBodyBytes) {
        if (!res.headersSent && !closed(res, abort)) {
          res.writeHead(413, { connection: 'close' })
          res.end()
        }
        req.destroy()
        return
      }
      chunks.push(chunk)
    }
    if (closed(res, abort)) return
    const request = new Request(new URL(req.url || '/', 'http://dsh.internal'), {
      method: req.method || 'GET',
      headers: Object.fromEntries(
        Object.entries(req.headers).filter(function (entry) {
          return typeof entry[1] === 'string'
        })
      ),
      ...(chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}),
      signal: abort.signal
    })
    const response = await apiHandler.fetch(request)
    if (closed(res, abort)) {
      await cancelBody(response.body)
      return
    }
    res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
    if (response.body === null) {
      res.end()
      return
    }
    try {
      for await (const chunk of response.body) {
        if (closed(res, abort)) {
          await cancelBody(response.body)
          return
        }
        if (!res.write(chunk)) {
          await waitWritable(res)
          if (closed(res, abort)) {
            await cancelBody(response.body)
            return
          }
        }
      }
    } catch (err) {
      if (closed(res, abort)) return
      throw err
    }
    if (!res.writableEnded) res.end()
  } finally {
    res.off('close', onClose)
  }
}
