import * as WebBrowser from 'expo-web-browser'
import type { Status } from 'src/lib/api-types'
import { hashtagOnlyLine, hashtagToken } from 'src/lib/text/fediverse-patterns'

// `src/state/storage` pulls in `react-native-mmkv` -> `react-native-nitro-
// modules`, which jest-expo can't resolve. The autolinker /
// openBrowserAllowlist test surfaces transitively import this module, so
// the storage import has to stay lazy — same workaround as `expo-linking`
// below. Resolves once per process to keep the hot-path call cheap;
// falls back to `'external'` (the default) when the module can't be
// loaded (test environments without MMKV native binding).
let _getOpenLinksTarget: (() => 'external' | 'inApp') | null = null
function readOpenLinksTarget(): 'external' | 'inApp' {
  if (_getOpenLinksTarget) return _getOpenLinksTarget()
  try {
    _getOpenLinksTarget = (
      require('src/state/storage') as typeof import('src/state/storage')
    ).getOpenLinksTarget
    return _getOpenLinksTarget()
  } catch {
    return 'external'
  }
}

// Pixelfed servers stamp `reply_count` (singular); Mastodon stamps
// `replies_count` (plural). Halflight's `StatusService::getMastodon`
// aliases both, but every consumer outside this helper that reads only
// one field will silently zero on the other server. Route through here.
export function getReplyCount(
  s: Pick<Status, 'reply_count' | 'replies_count'> | null | undefined
): number {
  if (!s) return 0
  return s.reply_count ?? s.replies_count ?? 0
}

// Web URL allowlist for in-app and system-browser opens. Server-supplied URLs
// (status `url`/`uri`, account `url`, link-preview cards, autolink hits, link
// rows) reach these wrappers — gating on `https?:` defeats `javascript:`,
// `data:`, `intent:` (Android), `file:`, and any custom scheme an attacker-
// crafted status body can smuggle in via an autolinked anchor.
function isHttpUrl(url: string): boolean {
  try {
    const parsed = new URL(url)
    return parsed.protocol === 'https:' || parsed.protocol === 'http:'
  } catch {
    return false
  }
}

// Resolver: route through the system default browser (`Linking.openURL`,
// iOS 14+ respects the user's Settings → Default Browser App choice) or
// SFSafariViewController / Chrome Custom Tabs, per the global `ui.openLinks`
// pref. `safeOpenStatusOriginal` / `safeOpenOriginUrl` / `LinkPreviewCard` /
// AutolinkText / Settings → Server links all call through here, so the
// toggle is felt everywhere the app opens an http(s) URL — no per-call-site
// branching, and the CVE-2024-23832-class hostname gates stay centralized.
//
// The fallthrough on `openExternalUrl` returning `false` is the Android
// edge case where no system browser is installed / no intent-filter
// matches — we'd rather show the link in-app than silently swallow.
export const openBrowserAsync = async (url: string) => {
  if (!isHttpUrl(url)) return
  if (readOpenLinksTarget() === 'external') {
    if (await openExternalUrl(url)) return
  }
  await WebBrowser.openBrowserAsync(url, {
    toolbarColor: '#000000',
    enableBarCollapsing: true,
    dismissButtonStyle: 'close',
  })
}

// `Linking.openURL` wrapper for paths that hand off to the system browser
// (admin → manage user, future "open in Safari/Chrome" surfaces). Same
// http(s)-only contract as `openBrowserAsync`. Returns false silently on a
// rejected URL so callers don't need to wrap in try/catch.
//
// `expo-linking` is lazy-required: importing it at module load wires up the
// EXDevLauncher constants which jest-expo's preset can't resolve, and the
// large feed-rendering test surface in `__tests__/CommentItemActions.tsx`
// transitively imports from this module via `CommentItem.tsx`.
export const openExternalUrl = async (url: string): Promise<boolean> => {
  if (!isHttpUrl(url)) return false
  try {
    const Linking = require('expo-linking') as typeof import('expo-linking')
    await Linking.openURL(url)
    return true
  } catch {
    return false
  }
}

// CVE-2024-23832-class belt-and-braces. We ship the corresponding fix in
// our Pixelfed fork's `Helpers::validateStatusUrls`, but the mobile app
// trusts whatever the home server returns — if a hostile remote stamps a
// Status with `id = https://victim.example/posts/X` and the home server
// missed the validation, opening "view original" would land the user on
// the spoofed host under the impersonated identity.
//
// `Status.url` is the home-server's view of the post (rendered HTML page);
// `Status.uri` is the canonical ActivityPub `id`. They MUST resolve to the
// same hostname — anything else is a federation forgery primitive. Returns
// silently (with a `__DEV__`-only warn) on mismatch so the UI doesn't
// surface a false-positive error to the user.
export const safeOpenStatusOriginal = async (
  status: Pick<Status, 'url' | 'uri'> | null | undefined
): Promise<void> => {
  if (!status) return
  const url = status.url || status.uri
  if (!url) return
  if (status.url && status.uri && status.url !== status.uri) {
    let urlHost: string
    let uriHost: string
    try {
      urlHost = new URL(status.url).hostname.toLowerCase()
      uriHost = new URL(status.uri).hostname.toLowerCase()
    } catch {
      return
    }
    if (urlHost !== uriHost) {
      if (__DEV__) {
        console.warn(
          `[safeOpenStatusOriginal] host mismatch url=${urlHost} uri=${uriHost}; refusing to open`
        )
      }
      return
    }
  }
  await openBrowserAsync(url)
}

// Same-host gate for the "View original" / "Open on origin" affordances on
// federated event and article detail views. These derive `originUrl` from
// `pf_source.remote_url`, `event.external_url`, `article.canonical_url`, or
// `post.url` — every one of them remote-server-controlled and not validated
// against the post's canonical AP `id` by the home server's `pf_source`
// passthrough. Without this gate a hostile remote can stamp a federated
// event/article with `pf_source.remote_url = https://victim.example/...` and
// the user tapping "Open on origin" lands on the spoofed host. Same shape
// as CVE-2024-23832; the home-server `validateStatusUrls` fix only covers
// `Status.url` ↔ `Status.uri`, not the Pixelfed-extension `pf_source` fields.
//
// A federated article whose author published a `canonical_url` on a custom
// domain (legitimate but rare in WriteFreely-style flows) will be refused
// here — accepted tradeoff per the phone-home audit §12.2 decision.
export const safeOpenOriginUrl = async (
  originUrl: string | null | undefined,
  statusUri: string | null | undefined
): Promise<void> => {
  if (!originUrl) return
  if (statusUri) {
    let originHost: string
    let uriHost: string
    try {
      originHost = new URL(originUrl).hostname.toLowerCase()
      uriHost = new URL(statusUri).hostname.toLowerCase()
    } catch {
      return
    }
    if (originHost !== uriHost) {
      if (__DEV__) {
        console.warn(
          `[safeOpenOriginUrl] host mismatch origin=${originHost} uri=${uriHost}; refusing to open`
        )
      }
      return
    }
  }
  await openBrowserAsync(originUrl)
}

export function formatTimestamp(iso8601String: string) {
  const now = new Date()
  const timestamp = new Date(iso8601String)
  const diffInSeconds = Math.floor((now.getTime() - timestamp.getTime()) / 1000)
  const diffInMinutes = Math.floor(diffInSeconds / 60)
  const diffInHours = Math.floor(diffInMinutes / 60)

  // Formatting for less than 24 hours
  if (diffInHours < 24) {
    if (diffInHours > 0) {
      return `${diffInHours}h ago`
    }
    if (diffInMinutes > 0) {
      return `${diffInMinutes}m ago`
    }
    return `Just now`
  }

  const monthNames = [
    'January',
    'February',
    'March',
    'April',
    'May',
    'June',
    'July',
    'August',
    'September',
    'October',
    'November',
    'December',
  ]
  const year = timestamp.getFullYear()
  const month = monthNames[timestamp.getMonth()]
  const day = timestamp.getDate()

  return `${month} ${day}, ${year}`
}

export function formatTimestampMonthYear(iso8601String: string) {
  const timestamp = new Date(iso8601String)

  const monthNames = [
    'January',
    'February',
    'March',
    'April',
    'May',
    'June',
    'July',
    'August',
    'September',
    'October',
    'November',
    'December',
  ]
  const year = timestamp.getFullYear()
  const month = monthNames[timestamp.getMonth()]

  return `${month} ${year}`
}

export function timeAgo(ts: string) {
  let date = Date.parse(ts)
  let seconds = Math.floor((Date.now() - date) / 1000)
  let interval = Math.floor(seconds / 63072000)
  if (interval < 0) {
    return '0s'
  }
  if (interval >= 1) {
    return interval + 'y'
  }
  interval = Math.floor(seconds / 604800)
  if (interval >= 1) {
    return interval + 'w'
  }
  interval = Math.floor(seconds / 86400)
  if (interval >= 1) {
    return interval + 'd'
  }
  interval = Math.floor(seconds / 3600)
  if (interval >= 1) {
    return interval + 'h'
  }
  interval = Math.floor(seconds / 60)
  if (interval >= 1) {
    return interval + 'm'
  }
  return Math.floor(seconds) + 's'
}

export function parseLinkHeader(header: string | null) {
  if (!header || header.length === 0) {
    return null
  }

  const parts = header.split(',')
  const links: Record<string, string> = {}
  parts.forEach((p) => {
    const section = p.split(';')
    const url = section[0].replace(/<(.*)>/, '$1').trim()
    const name = section[1].replace(/rel="(.*)"/, '$1').trim()
    if (hasEmptyPaginationCursor(url)) return
    links[name] = url
  })

  return links
}

// Vanilla Pixelfed (pixelfed/pixelfed#5698) emits a non-progressing Link
// header — `?…&max_id=` with the cursor value empty — whenever a page
// contains a single item. The client would otherwise follow it forever:
// Laravel's ConvertEmptyStringsToNull middleware coerces the empty value
// to null, the controller falls back to its `min_id=1` default, and
// returns the same row + the same broken Link header indefinitely.
// Drop any Link entry whose pagination cursor is present-but-empty.
function hasEmptyPaginationCursor(url: string): boolean {
  let parsed: URL
  try {
    parsed = new URL(url)
  } catch {
    return true
  }
  for (const key of ['max_id', 'min_id', 'since_id', 'cursor']) {
    if (parsed.searchParams.get(key) === '') return true
  }
  return false
}

export function prettyCount(number: number) {
  if (!number) {
    return 0
  }
  if (number < 1000) return number
  if (number < 1000000) {
    return (number / 1000).toFixed(number % 1000 === 0 ? 0 : 1) + 'K'
  }
  if (number < 1000000000) {
    return (number / 1000000).toFixed(number % 1000000 === 0 ? 0 : 1) + 'M'
  }
  return (number / 1000000000).toFixed(number % 1000000000 === 0 ? 0 : 1) + 'B'
}

export function likeCountLabel(number: number) {
  if (!number) {
    return ''
  }

  if (number == 1) {
    return '1 Like'
  }

  return `${prettyCount(number)} Likes`
}

export function postCountLabel(number: number) {
  if (!number) {
    return '0 Posts'
  }

  if (number == 1) {
    return '1 Post'
  }

  return `${prettyCount(number)} Posts`
}

export function enforceLen(str: string, len: number, ellipsis = false, mode = 'end') {
  str = str || ''
  if (str.length > len) {
    if (ellipsis && mode === 'end') {
      return str.slice(0, len) + '…'
    }
    if (ellipsis && mode === 'middle') {
      const half = Math.floor(len / 2)
      return str.slice(0, half) + '…' + str.slice(-half)
    }
    return str.slice(0, len)
  }
  return str
}

// Rendered Pixelfed status content is HTML. The feed renders it as flat
// plain text, which dropped paragraph breaks, blockquotes, and code blocks
// on the floor. This walker preserves the structural whitespace a reader
// would expect to see:
//
//   <p>…</p><p>…</p>          → paragraph break (two newlines)
//   <br>, <br/>               → single newline
//   <blockquote>…</blockquote>→ each line prefixed with "> "
//   <pre>…</pre>              → inner whitespace preserved
//   <code>…</code>            → inline, whitespace preserved
//   <li>…</li>                → "• " bullet prefix, one per line
//
// Remaining tags are stripped, their inner text kept. HTML entities that
// appear in server output (`&amp;`, `&#39;`, `&nbsp;`, …) are decoded.
export function htmlToTextWithLineBreaks(html: string) {
  if (!html || !html.length) {
    return html
  }

  let out = html

  // <br> before tag stripping so line breaks inside paragraphs survive.
  out = out.replace(/<br\s*\/?\s*>/gi, '\n')

  // <blockquote> — strip inner tags, prefix each line with "> ". Wrap in
  // paragraph breaks so adjacent paragraphs don't collapse onto the quote.
  out = out.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_m, inner) => {
    const innerText = stripTags(inner).trim()
    if (!innerText) return '\n\n'
    const prefixed = innerText
      .split('\n')
      .map((line) => '> ' + line)
      .join('\n')
    return '\n\n' + prefixed + '\n\n'
  })

  // <pre> and inline <code>: preserve inner whitespace, strip nested tags.
  // `<pre><code>…</code></pre>` is the Pixelfed/Mastodon convention.
  out = out.replace(
    /<pre[^>]*>([\s\S]*?)<\/pre>/gi,
    (_m, inner) => '\n\n' + stripTags(inner) + '\n\n'
  )
  out = out.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_m, inner) => stripTags(inner))

  // <li>: prefix each list item with a bullet + space. Outer <ul>/<ol>
  // tags are stripped by the final tag strip below.
  out = out.replace(
    /<li[^>]*>([\s\S]*?)<\/li>/gi,
    (_m, inner) => '\n• ' + stripTags(inner).trim()
  )

  // <a>: Mastodon/Pixelfed anchor text drops the URL scheme (e.g. the visible
  // text for `<a href="https://example.com/foo">example.com/foo</a>` is just
  // `example.com/foo`). Stripping the tag blindly would leave `example.com/foo`
  // in the output, which AutolinkText's linkRegex (requires `https?://`) then
  // fails to match — the URL stops being tappable. Substitute the href for
  // URL anchors so the scheme survives into the autolinker; keep inner text
  // for mention/hashtag anchors (both start with `@` or `#` after tag strip)
  // so the existing mention/hashtag regexes still fire.
  out = out.replace(
    /<a\b[^>]*\bhref=(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi,
    (_m, hrefDq, hrefSq, inner) => {
      const href = hrefDq || hrefSq || ''
      const innerText = stripTags(inner).trim()
      if (innerText.startsWith('@') || innerText.startsWith('#')) return innerText
      return href || innerText
    }
  )

  // Paragraphs: closing tag becomes a paragraph break, opening tag is dropped.
  out = out.replace(/<\/p>/gi, '\n\n')
  out = out.replace(/<p[^>]*>/gi, '')

  out = stripTags(out)
  out = decodeHtmlEntities(out)
  // Collapse runs of 3+ blank lines into a single paragraph break.
  out = out.replace(/\n{3,}/g, '\n\n')
  return out.trim()
}

function stripTags(s: string): string {
  return s.replace(/<[^>]+>/g, '')
}

function decodeHtmlEntities(s: string): string {
  return s
    .replaceAll('&#39;', "'")
    .replaceAll('&apos;', "'")
    .replaceAll('&quot;', '"')
    .replaceAll('&nbsp;', ' ')
    .replaceAll('&lt;', '<')
    .replaceAll('&gt;', '>')
    .replaceAll('&amp;', '&')
}

// Strip server-side hint lines that are redundant once we render the
// structured content natively:
//   - leading `RE: <url>` paragraph on quote posts (Pixelfed back-compat shim)
//   - Mobilizon's Mastodon-targeted event hints at the start of `summary`:
//     "⭐ favourite to show interest", "📍 Join online at …", "🗓 Add to your
//     calendar", etc. The EventCard RSVP buttons + location row already
//     communicate these affordances.
// Strips up to three consecutive leading hint lines (each in its own <p>)
// so multi-hint summaries are cleaned in one pass.
const HINT_LINE_PATTERN =
  /^\s*(?:<p[^>]*>)?\s*[⭐🌟📍🗓📅🎟🎫🔗📢📣🌍🌐][^<\n]*(?:<\/p>)?\s*/iu

export function preprocessStatusContent(content: string): string {
  if (!content) return content
  let out = content.replace(
    /^\s*<p[^>]*class="[^"]*\bquote-inline\b[^"]*"[^>]*>[\s\S]*?<\/p>\s*/i,
    ''
  )
  for (let i = 0; i < 3; i++) {
    const next = out.replace(HINT_LINE_PATTERN, '')
    if (next === out) break
    out = next
  }
  return out
}

// A trailing "wall of hashtags" — authors often park `#foo #bar #baz` at the
// end of a post, separate from the caption body. Walks the text from the last
// line backwards, collecting lines that are hashtag-only (hashtag tokens
// optionally separated by whitespace or commas). Stops at the first non-
// hashtag line and returns the stripped body plus the deduped tag list.
//
// Inline hashtags mid-sentence stay put — only lines that are entirely tags
// get pulled. If extraction would leave an empty body (post is nothing but
// tags), we leave everything inline.
//
// `hashtagToken` / `hashtagOnlyLine` come from the shared
// `src/lib/text/fediverse-patterns` module so the pill extractor and the
// inline `AutolinkText` autolinker agree on what a hashtag is — a tag must
// contain ≥1 letter (so pure-numeric `#100` and hyphenated `#foo-bar` are
// not treated as a single tag, matching the Pixelfed/Mastodon server lexers).
export function extractTrailingHashtags(text: string): {
  body: string
  hashtags: string[]
} {
  if (!text) return { body: text, hashtags: [] }
  const lines = text.split('\n')
  let cut = lines.length
  for (let i = lines.length - 1; i >= 0; i--) {
    const line = lines[i]
    if (line.trim() === '') continue
    if (!hashtagOnlyLine.test(line)) {
      cut = i + 1
      break
    }
    cut = i
  }
  if (cut >= lines.length) return { body: text, hashtags: [] }
  const tail = lines.slice(cut).join('\n')
  const collected: string[] = []
  const seen = new Set<string>()
  for (const m of tail.matchAll(hashtagToken)) {
    const tag = m[0].slice(1)
    const lower = tag.toLowerCase()
    if (seen.has(lower)) continue
    seen.add(lower)
    collected.push(tag)
  }
  if (collected.length === 0) return { body: text, hashtags: [] }
  const body = lines.slice(0, cut).join('\n').trimEnd()
  // If stripping the tail erased the whole caption, keep everything inline —
  // a pill-only post would lose all context.
  if (!body) return { body: text, hashtags: [] }
  return { body, hashtags: collected }
}

// Slice `text` at `max` chars and append an ellipsis. Backs off if the cut
// falls inside a `:shortcode:` so the truncated string never leaks raw `:`
// punctuation (which would render as broken emoji syntax in the collapsed
// view). Shared between `StatusContent` and `ExpandableText`.
export function truncateKeepingShortcodes(text: string, max: number): string {
  if (text.length <= max) return text
  let cut = max
  const shortcodeRe = /:[a-zA-Z0-9_]{2,}:/g
  for (let m: RegExpExecArray | null; (m = shortcodeRe.exec(text)); ) {
    if (m.index >= cut) break
    const end = m.index + m[0].length
    if (cut < end) {
      cut = m.index
      break
    }
  }
  return text.slice(0, cut).trimEnd() + '…'
}

export function extractMainVersion(version: string) {
  const regex = /^(\d+\.\d+\.\d+)/
  const match = version.match(regex)
  if (match) {
    return match[1]
  }
  return null
}

export function compareSemver(version1: string, version2: string) {
  const parseVersion = (version: string) => {
    const mainVersion = extractMainVersion(version)
    return mainVersion ? mainVersion.split('.').map(Number) : [0, 0, 0]
  }

  const [major1, minor1, patch1] = parseVersion(version1)
  const [major2, minor2, patch2] = parseVersion(version2)

  if (major1 > major2) return 1
  if (major1 < major2) return -1

  if (minor1 > minor2) return 1
  if (minor1 < minor2) return -1

  if (patch1 > patch2) return 1
  if (patch1 < patch2) return -1

  return 0
}

export function getDomain(url: string) {
  let domain = new URL(url)
  return domain.hostname
}
