/**
 * Zoff Wallet — background service worker (Manifest V3).
 *
 * Pure JSON-RPC router for the `window.cantonWallet` provider. The SW is
 * **stateless across kills** — every piece of state that matters
 * (pending requests, approved-origins map, the popup's session token)
 * lives in `chrome.storage.local` / `chrome.storage.session`. The SW
 * just dispatches; popup decryption, signing, and party allocation
 * happen in popup pages that own their own crypto context.
 *
 * Two message channels:
 *
 *   1. `chrome.runtime.onMessage` from `content.js`:
 *      `{type:'zoff-rpc', reqId, method, params, origin}` →
 *      route by method, reply with `{result}` / `{error}` synchronously
 *      OR `{deferred:true}` if an approval popup is needed.
 *
 *   2. `chrome.runtime.onMessage` from approval popup pages:
 *      `{type:'zoff-approval-result', reqId, result?, error?}` →
 *      look up the saved pending request, push the result back to
 *      the originating tab via `chrome.tabs.sendMessage`.
 *
 * State shape (chrome.storage.local):
 *   - `zoff:approved-origins` → `Record<origin, ApprovedOriginEntry>`
 *   - `zoff:pending:<reqId>` → `PendingApprovalRequest`
 *
 * The TS-side mirrors of these shapes live in
 * `frontend/lib/extension-rpc.ts`. This file is plain JS (SW context
 * cannot import TS) so the shapes are kept in sync by hand.
 */

const STORAGE_KEYS = {
  APPROVED_ORIGINS: 'zoff:approved-origins',
  PENDING_PREFIX: 'zoff:pending:',
}

const SESSION_TOKEN_KEY = 'zoff-popup-session-token'
const KEYSTORE_KEY = 'canton_wallet_keystore'
const ACTIVE_INDEX_KEY = 'zoff-popup-active-index'
const ACCOUNTS_KEY = 'canton-wallet-accounts'

const ERROR = {
  USER_REJECTED: 4001,
  UNAUTHORIZED: 4100,
  UNSUPPORTED_METHOD: 4200,
  INVALID_PARAMS: -32602,
  INTERNAL_ERROR: -32603,
}

/**
 * Resolve the backend URL from the manifest's `host_permissions`. Single
 * source of truth — the dev manifest lists `http://localhost:3001/*` first,
 * so `hosts[0]` is the dev backend in sandbox builds. The prod build
 * (`build:extension:prod`) runs `scripts/build-extension.mjs` which strips
 * every `localhost:*` entry, leaving `https://api.zoff.app/*` at index 0
 * for the devnet build. No env-substitution gymnastics needed (the SW is
 * plain JS — Next does not bundle it).
 */
function resolveBackendUrl() {
  try {
    const hosts = chrome.runtime.getManifest().host_permissions || []
    if (hosts[0]) return hosts[0].replace(/\/\*$/, '').replace(/\/$/, '')
  } catch {
    // chrome.runtime not yet ready — fall through to the literal default.
  }
  return 'http://localhost:3001'
}

const BACKEND_URL = resolveBackendUrl()

console.log('[Zoff SW] Service worker started, backend:', BACKEND_URL)

chrome.runtime.onInstalled.addListener((details) => {
  console.log('[Zoff SW] Installed:', details.reason)
})

// ── chrome.storage helpers (promise wrappers) ──────────────────────────────

function storageLocalGet(key) {
  return new Promise((resolve) => {
    chrome.storage.local.get(key, (items) => {
      resolve(items)
    })
  })
}

function storageLocalSet(items) {
  return new Promise((resolve) => {
    chrome.storage.local.set(items, () => resolve())
  })
}

function storageLocalRemove(key) {
  return new Promise((resolve) => {
    chrome.storage.local.remove(key, () => resolve())
  })
}

function storageSessionGet(key) {
  return new Promise((resolve) => {
    if (!chrome.storage.session) {
      resolve({})
      return
    }
    chrome.storage.session.get(key, (items) => resolve(items))
  })
}

// ── approved-origins map ──────────────────────────────────────────────────

async function getApprovedOrigins() {
  const result = await storageLocalGet(STORAGE_KEYS.APPROVED_ORIGINS)
  const map = result[STORAGE_KEYS.APPROVED_ORIGINS]
  return map && typeof map === 'object' ? map : {}
}

async function setApprovedOrigin(origin, entry) {
  const map = await getApprovedOrigins()
  map[origin] = entry
  await storageLocalSet({ [STORAGE_KEYS.APPROVED_ORIGINS]: map })
}

async function removeApprovedOrigin(origin) {
  const map = await getApprovedOrigins()
  if (map[origin]) {
    delete map[origin]
    await storageLocalSet({ [STORAGE_KEYS.APPROVED_ORIGINS]: map })
  }
}

// ── pending approval requests ─────────────────────────────────────────────

async function savePendingRequest(req) {
  await storageLocalSet({ [STORAGE_KEYS.PENDING_PREFIX + req.reqId]: req })
}

async function loadPendingRequest(reqId) {
  const result = await storageLocalGet(STORAGE_KEYS.PENDING_PREFIX + reqId)
  return result[STORAGE_KEYS.PENDING_PREFIX + reqId] || null
}

async function clearPendingRequest(reqId) {
  await storageLocalRemove(STORAGE_KEYS.PENDING_PREFIX + reqId)
}

// ── popup-shared state (read by SW for getBalances etc.) ───────────────────

async function getPopupSessionToken() {
  const session = await storageSessionGet(SESSION_TOKEN_KEY)
  let raw = session[SESSION_TOKEN_KEY]
  if (!raw) {
    // Fallback to .local for browsers without chrome.storage.session.
    const local = await storageLocalGet(SESSION_TOKEN_KEY)
    raw = local[SESSION_TOKEN_KEY]
  }
  if (!raw || typeof raw !== 'object') return null
  if (typeof raw.token !== 'string' || typeof raw.expiresAt !== 'number') {
    return null
  }
  if (raw.expiresAt <= Date.now()) return null
  return raw
}

async function getActiveAccount() {
  const [accountsResult, indexResult] = await Promise.all([
    storageLocalGet(ACCOUNTS_KEY),
    storageLocalGet(ACTIVE_INDEX_KEY),
  ])
  const accounts = accountsResult[ACCOUNTS_KEY]
  if (!Array.isArray(accounts) || accounts.length === 0) return null
  const idx =
    typeof indexResult[ACTIVE_INDEX_KEY] === 'number'
      ? indexResult[ACTIVE_INDEX_KEY]
      : 0
  return accounts[idx] || accounts[0] || null
}

// ── accountChanged broadcast ──────────────────────────────────────────────

/**
 * Push an `accountChanged` event to every tab that has an approved origin.
 * The content script forwards it to the page world via `zoff-injected-event`,
 * and the provider's `on('accountChanged', cb)` fires in the dApp.
 */
async function broadcastAccountChanged(partyId) {
  const map = await getApprovedOrigins()
  const approvedOrigins = new Set(Object.keys(map))
  if (approvedOrigins.size === 0) return

  const tabs = await chrome.tabs.query({})
  for (const tab of tabs) {
    if (!tab.id || !tab.url) continue
    try {
      const tabOrigin = new URL(tab.url).origin
      if (approvedOrigins.has(tabOrigin)) {
        await chrome.tabs.sendMessage(tab.id, {
          type: 'zoff-event',
          event: 'accountChanged',
          payload: partyId,
        })
      }
    } catch {
      // Tab may not have the content script loaded — ignore
    }
  }
}

// ── method dispatch ────────────────────────────────────────────────────────

/**
 * Dispatch a JSON-RPC request from a content script. Returns either:
 *   - `{result}` for synchronous methods
 *   - `{error}` for synchronous failures
 *   - `{deferred: true}` when an approval popup was opened; the real
 *     result will be pushed later via `chrome.tabs.sendMessage`.
 */
async function dispatchRpc(message, sender) {
  const { method, params, origin, reqId } = message

  switch (method) {
    case 'isConnected': {
      const map = await getApprovedOrigins()
      return { result: Boolean(map[origin]) }
    }

    case 'disconnect': {
      await removeApprovedOrigin(origin)
      return { result: true }
    }

    case 'getPartyId': {
      const map = await getApprovedOrigins()
      const entry = map[origin]
      if (!entry) {
        return {
          error: {
            code: ERROR.UNAUTHORIZED,
            message: 'Not connected. Call wallet.connect() first.',
          },
        }
      }
      return { result: entry.partyId }
    }

    case 'getBalances': {
      const map = await getApprovedOrigins()
      const entry = map[origin]
      if (!entry) {
        return {
          error: {
            code: ERROR.UNAUTHORIZED,
            message: 'Not connected. Call wallet.connect() first.',
          },
        }
      }
      // Pull the popup's session token. If absent or expired, the user
      // needs to open the popup and unlock — return a clear UNAUTHORIZED
      // so the dApp can prompt them.
      const session = await getPopupSessionToken()
      if (!session) {
        return {
          error: {
            code: ERROR.UNAUTHORIZED,
            message: 'Wallet is locked. Open Zoff and unlock to continue.',
          },
        }
      }
      try {
        const res = await fetch(
          `${BACKEND_URL}/balances/${encodeURIComponent(entry.partyId)}`,
          {
            headers: { Authorization: `Bearer ${session.token}` },
          },
        )
        if (!res.ok) {
          const body = await res.json().catch(() => ({}))
          return {
            error: {
              code: ERROR.INTERNAL_ERROR,
              message: body.error || `Backend error: ${res.status}`,
            },
          }
        }
        const data = await res.json()
        // Reduce to the BalanceItem shape the dApp expects.
        const balances = (data.balances || []).map((b) => ({
          symbol: b.symbol,
          amount: b.amount,
          decimals: b.decimals,
          contractId: b.contractId,
        }))
        return { result: balances }
      } catch (err) {
        return {
          error: {
            code: ERROR.INTERNAL_ERROR,
            message: err instanceof Error ? err.message : 'Backend unreachable',
          },
        }
      }
    }

    case 'connect': {
      // Idempotent: if origin already approved, return the cached entry
      // immediately without re-prompting.
      const map = await getApprovedOrigins()
      if (map[origin]) {
        return {
          result: {
            partyId: map[origin].partyId,
            displayName: map[origin].displayName,
            publicKey: map[origin].publicKey,
            permissions: map[origin].permissions,
          },
        }
      }
      // Need user approval — open the connect popup and defer the result.
      if (!sender || !sender.tab || typeof sender.tab.id !== 'number') {
        return {
          error: {
            code: ERROR.INTERNAL_ERROR,
            message: 'Cannot identify originating tab',
          },
        }
      }
      await savePendingRequest({
        reqId,
        method: 'connect',
        origin,
        params: params || {},
        tabId: sender.tab.id,
        createdAt: Date.now(),
      })
      const url = chrome.runtime.getURL(
        `approval/connect.html?reqId=${encodeURIComponent(reqId)}`,
      )
      try {
        const win = await chrome.windows.create({
          url,
          type: 'popup',
          width: 380,
          height: 600,
          focused: true,
        })
        // Track the window so we can detect if the user closes it
        // without choosing — that counts as a reject.
        if (win && typeof win.id === 'number') {
          await storageLocalSet({
            [`zoff:window:${win.id}`]: { reqId },
          })
        }
      } catch (err) {
        await clearPendingRequest(reqId)
        return {
          error: {
            code: ERROR.INTERNAL_ERROR,
            message: 'Failed to open approval popup',
          },
        }
      }
      return { deferred: true }
    }

    case 'signAndSubmit': {
      const map = await getApprovedOrigins()
      const entry = map[origin]
      if (!entry) {
        return {
          error: {
            code: ERROR.UNAUTHORIZED,
            message: 'Not connected. Call wallet.connect() first.',
          },
        }
      }
      if (!entry.permissions || !entry.permissions.includes('sign')) {
        return {
          error: {
            code: ERROR.UNAUTHORIZED,
            message: 'Sign permission was not granted to this origin.',
          },
        }
      }
      if (!params || typeof params !== 'object' || !params.transaction) {
        return {
          error: {
            code: ERROR.INVALID_PARAMS,
            message: 'signAndSubmit requires { transaction }',
          },
        }
      }
      if (!sender || !sender.tab || typeof sender.tab.id !== 'number') {
        return {
          error: {
            code: ERROR.INTERNAL_ERROR,
            message: 'Cannot identify originating tab',
          },
        }
      }
      await savePendingRequest({
        reqId,
        method: 'signAndSubmit',
        origin,
        params: { transaction: params.transaction, partyId: entry.partyId },
        tabId: sender.tab.id,
        createdAt: Date.now(),
      })
      const url = chrome.runtime.getURL(
        `approval/sign.html?reqId=${encodeURIComponent(reqId)}`,
      )
      try {
        const win = await chrome.windows.create({
          url,
          type: 'popup',
          width: 380,
          height: 640,
          focused: true,
        })
        if (win && typeof win.id === 'number') {
          await storageLocalSet({
            [`zoff:window:${win.id}`]: { reqId },
          })
        }
      } catch {
        await clearPendingRequest(reqId)
        return {
          error: {
            code: ERROR.INTERNAL_ERROR,
            message: 'Failed to open sign popup',
          },
        }
      }
      return { deferred: true }
    }

    default:
      return {
        error: {
          code: ERROR.UNSUPPORTED_METHOD,
          message: `Unknown method: ${method}`,
        },
      }
  }
}

// ── content-script → SW request channel ───────────────────────────────────

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (!message || typeof message !== 'object') return false

  if (message.type === 'zoff-rpc') {
    dispatchRpc(message, sender)
      .then((response) => sendResponse(response))
      .catch((err) => {
        console.error('[Zoff SW] dispatch error:', err)
        sendResponse({
          error: {
            code: ERROR.INTERNAL_ERROR,
            message: err instanceof Error ? err.message : 'SW error',
          },
        })
      })
    return true // keep channel open for async sendResponse
  }

  if (message.type === 'zoff-account-changed') {
    // Broadcast accountChanged event to all tabs that have an approved origin.
    broadcastAccountChanged(message.partyId)
      .then(() => sendResponse({ ok: true }))
      .catch(() => sendResponse({ ok: false }))
    return true
  }

  if (message.type === 'zoff-approval-result') {
    handleApprovalResult(message)
      .then(() => sendResponse({ ok: true }))
      .catch((err) => {
        console.error('[Zoff SW] approval-result error:', err)
        sendResponse({ ok: false, error: String(err) })
      })
    return true
  }

  return false
})

// ── approval popup → SW result channel ────────────────────────────────────

async function handleApprovalResult(message) {
  const { reqId, result, error } = message
  if (!reqId) return
  const pending = await loadPendingRequest(reqId)
  if (!pending) {
    console.warn('[Zoff SW] approval result for unknown reqId:', reqId)
    return
  }

  // For successful `connect` approvals, persist the approved-origin entry.
  if (pending.method === 'connect' && result && !error) {
    await setApprovedOrigin(pending.origin, {
      origin: pending.origin,
      partyId: result.partyId,
      displayName: result.displayName,
      publicKey: result.publicKey,
      permissions: result.permissions || ['read'],
      approvedAt: Date.now(),
    })
  }

  await clearPendingRequest(reqId)

  // Push the result back to the originating tab.
  try {
    await chrome.tabs.sendMessage(pending.tabId, {
      type: 'zoff-rpc-result',
      reqId,
      result,
      error,
    })
  } catch (err) {
    console.warn('[Zoff SW] Failed to push result to tab', pending.tabId, err)
  }
}

// ── close-as-reject: detect popup window close without an explicit choice ─

chrome.windows.onRemoved.addListener(async (windowId) => {
  const key = `zoff:window:${windowId}`
  const result = await storageLocalGet(key)
  const trackedWindow = result[key]
  await storageLocalRemove(key)
  if (!trackedWindow || !trackedWindow.reqId) return

  // If the pending request still exists, the user closed the popup
  // without choosing — synthesise a reject and push it through the
  // normal result path.
  const pending = await loadPendingRequest(trackedWindow.reqId)
  if (!pending) return // already handled

  await handleApprovalResult({
    type: 'zoff-approval-result',
    reqId: trackedWindow.reqId,
    error: {
      code: ERROR.USER_REJECTED,
      message: 'User rejected request',
    },
  })
})
