// `openBrowserAsync` and `openExternalUrl` allowlist tests. P1c shipped a
// scheme gate that drops `javascript:`, `data:`, `intent:` (Android), `file:`,
// and any custom scheme an attacker-crafted status body can smuggle in via
// an autolinked anchor. Server-supplied URLs reach both wrappers (status
// `url`/`uri`, account `url`, link-preview cards, autolink hits, link rows,
// admin "manage user" → system browser) — without the gate, a status with
// `<a href="javascript:alert(1)">` would reach the in-app browser.
//
// `openBrowserAsync` now routes through a `ui.openLinks` resolver:
// `external` (default) → `Linking.openURL` (system default browser per
// iOS 14+ Settings → Default Browser App); `inApp` →
// `WebBrowser.openBrowserAsync` (SFSafariViewController / Chrome Custom
// Tabs). The MMKV pref isn't loadable under jest-expo, so the resolver's
// fallback returns `'external'` — assertions below pin `mockOpenURL`.
//
// Pins:
//   1. https URLs reach the underlying open call (now `Linking.openURL`).
//   2. http URLs (rare on the modern web but still legal) reach it too —
//      the gate is "web URL" not "TLS-only URL"; ATS already enforces TLS
//      at the transport layer (Info.plist NSAllowsArbitraryLoads = false).
//   3. Non-web schemes are dropped silently.
//   4. Malformed inputs are dropped silently (no throw).

const mockOpenBrowserAsync = jest.fn<Promise<unknown>, [string, unknown?]>(
  async () => undefined
)
const mockOpenURL = jest.fn<Promise<unknown>, [string]>(async () => undefined)

jest.mock('expo-web-browser', () => ({
  openBrowserAsync: (url: string, opts?: unknown) => mockOpenBrowserAsync(url, opts),
}))

jest.mock('expo-linking', () => ({
  openURL: (url: string) => mockOpenURL(url),
}))

type UtilsModule = typeof import('../src/utils')

let openBrowserAsync: UtilsModule['openBrowserAsync']
let openExternalUrl: UtilsModule['openExternalUrl']
let safeOpenOriginUrl: UtilsModule['safeOpenOriginUrl']

beforeAll(() => {
  const utils = require('../src/utils') as UtilsModule
  openBrowserAsync = utils.openBrowserAsync
  openExternalUrl = utils.openExternalUrl
  safeOpenOriginUrl = utils.safeOpenOriginUrl
})

beforeEach(() => {
  mockOpenBrowserAsync.mockClear()
  mockOpenURL.mockClear()
})

describe('openBrowserAsync — scheme allowlist', () => {
  it('opens https URLs (via system default browser)', async () => {
    await openBrowserAsync('https://pixelfed.social/p/foo/123')
    expect(mockOpenURL).toHaveBeenCalledTimes(1)
    expect(mockOpenURL).toHaveBeenCalledWith('https://pixelfed.social/p/foo/123')
  })

  it('opens http URLs (via system default browser)', async () => {
    await openBrowserAsync('http://example.com/legacy')
    expect(mockOpenURL).toHaveBeenCalledTimes(1)
  })

  it('drops javascript: URLs silently', async () => {
    await openBrowserAsync('javascript:alert(1)')
    expect(mockOpenBrowserAsync).not.toHaveBeenCalled()
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('drops data: URLs silently', async () => {
    await openBrowserAsync('data:text/html,<script>alert(1)</script>')
    expect(mockOpenBrowserAsync).not.toHaveBeenCalled()
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('drops file: URLs silently', async () => {
    await openBrowserAsync('file:///etc/passwd')
    expect(mockOpenBrowserAsync).not.toHaveBeenCalled()
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('drops intent: (Android) URLs silently', async () => {
    await openBrowserAsync('intent://example.com#Intent;scheme=https;end')
    expect(mockOpenBrowserAsync).not.toHaveBeenCalled()
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('drops custom-scheme URLs silently', async () => {
    await openBrowserAsync('halflight://login')
    expect(mockOpenBrowserAsync).not.toHaveBeenCalled()
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('drops malformed input without throwing', async () => {
    await openBrowserAsync('not a url at all')
    await openBrowserAsync('')
    expect(mockOpenBrowserAsync).not.toHaveBeenCalled()
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('case-insensitively recognises HTTP scheme', async () => {
    // URL parser lowercases `protocol` so HTTPS:// resolves to https: — the
    // gate doesn't need its own case-fold but pinning behaviour here
    // protects against a future regex-based rewrite that would.
    await openBrowserAsync('HTTPS://pixelfed.social/p/foo/123')
    expect(mockOpenURL).toHaveBeenCalledTimes(1)
  })

  it('falls back to in-app browser when system open rejects', async () => {
    // Android edge case: no installed browser / no intent-filter handler.
    mockOpenURL.mockImplementationOnce(async () => {
      throw new Error('no handler')
    })
    await openBrowserAsync('https://example.com/no-handler')
    expect(mockOpenBrowserAsync).toHaveBeenCalledTimes(1)
  })
})

describe('openExternalUrl — scheme allowlist', () => {
  it('opens https URLs and returns true', async () => {
    const ok = await openExternalUrl('https://pixelfed.social/i/admin/users/show/42')
    expect(ok).toBe(true)
    expect(mockOpenURL).toHaveBeenCalledWith(
      'https://pixelfed.social/i/admin/users/show/42'
    )
  })

  it('drops javascript: silently and returns false', async () => {
    const ok = await openExternalUrl('javascript:alert(1)')
    expect(ok).toBe(false)
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('drops malformed input silently and returns false', async () => {
    const ok = await openExternalUrl('this is not a url')
    expect(ok).toBe(false)
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('returns false when Linking.openURL rejects', async () => {
    mockOpenURL.mockImplementationOnce(async () => {
      throw new Error('cannot open')
    })
    const ok = await openExternalUrl('https://example.com/foo')
    expect(ok).toBe(false)
  })
})

// `safeOpenOriginUrl` gates the "Open on origin" affordance on federated
// event/article detail views. It must refuse when `originUrl`'s host doesn't
// match the post's canonical AP `uri` host — defends the same CVE-2024-23832
// shape that `safeOpenStatusOriginal` covers for `Status.url ↔ uri`, but for
// the `pf_source.remote_url` / `external_url` / `canonical_url` fields the
// home server doesn't validate.
describe('safeOpenOriginUrl — same-host gate', () => {
  it('opens when origin host matches status uri host', async () => {
    await safeOpenOriginUrl(
      'https://mobilizon.example/events/123',
      'https://mobilizon.example/events/123'
    )
    expect(mockOpenURL).toHaveBeenCalledTimes(1)
  })

  it('opens when origin and uri share a host but differ in path', async () => {
    await safeOpenOriginUrl(
      'https://writefreely.example/posts/abc',
      'https://writefreely.example/ap/articles/xyz'
    )
    expect(mockOpenURL).toHaveBeenCalledTimes(1)
  })

  it('refuses when origin host differs from status uri host', async () => {
    await safeOpenOriginUrl(
      'https://victim.example/wallet/transfer',
      'https://evil.example/events/123'
    )
    expect(mockOpenBrowserAsync).not.toHaveBeenCalled()
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('case-folds hostnames before comparing', async () => {
    await safeOpenOriginUrl(
      'https://Mobilizon.Example/events/123',
      'https://mobilizon.example/events/123'
    )
    expect(mockOpenURL).toHaveBeenCalledTimes(1)
  })

  it('drops null / undefined / empty origin without opening', async () => {
    await safeOpenOriginUrl(null, 'https://mobilizon.example/events/123')
    await safeOpenOriginUrl(undefined, 'https://mobilizon.example/events/123')
    await safeOpenOriginUrl('', 'https://mobilizon.example/events/123')
    expect(mockOpenBrowserAsync).not.toHaveBeenCalled()
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('opens when status uri is missing — nothing to validate against', async () => {
    // Local-only posts may not carry an AP `uri`. Falling through to the open
    // is the conservative-functional choice; the scheme allowlist still gates.
    await safeOpenOriginUrl('https://example.com/foo', null)
    expect(mockOpenURL).toHaveBeenCalledTimes(1)
  })

  it('refuses when origin url is malformed', async () => {
    await safeOpenOriginUrl('not a url', 'https://mobilizon.example/events/123')
    expect(mockOpenBrowserAsync).not.toHaveBeenCalled()
    expect(mockOpenURL).not.toHaveBeenCalled()
  })

  it('still applies the scheme allowlist after the host check passes', async () => {
    // A status.uri = javascript:... is degenerate but the gate must not let
    // it through even when "hosts match" (URL parser treats javascript: URLs
    // as opaque — both hostnames would parse to '').
    await safeOpenOriginUrl('javascript:alert(1)', 'javascript:alert(1)')
    expect(mockOpenBrowserAsync).not.toHaveBeenCalled()
    expect(mockOpenURL).not.toHaveBeenCalled()
  })
})
