import browser from 'webextension-polyfill'
import { isLikelyDownloadResponse, normalizeUrl } from '../lib/downloadIntent'
import { nativeAddUri, nativeFocus } from '../lib/nativeHost'
import { getSettings } from '../lib/settings'
import { upsertHistory } from '../lib/history'
import { makeMediaCandidate, mediaFingerprint, shouldCaptureMediaResponse } from '../lib/mediaCapture'
import { clearMediaCandidates, listMediaCandidates, upsertMediaCandidate } from '../lib/mediaStore'
import { deleteClip, getClipById, importClips, insertClip, listClips, listClipsByUrl, updateClipResolveStatus } from '../lib/clipStore'
import { normalizePageUrl, normalizeQuote, type ClipItem } from '../lib/clipTypes'

const REQUEST_TTL_MS = 8000
const TRANSFER_DEDUPE_TTL_MS = 7000
const contextMenuId = 'gomdown-helper-download-context-menu-option'
const OBSIDIAN_LAST_VAULT_KEY = 'obsidianLastVault'

const pendingRequests = new Map<string, any>()
const capturedUrls = new Map<string, number>()
const capturedFingerprints = new Map<string, number>()
const recentTransferFingerprints = new Map<string, number>()
const handledRequestIds = new Map<string, number>()
const capturedTabIds = new Map<number, number>()
const recentMediaFingerprints = new Map<string, number>()
let webRequestHooked = false
let downloadHooked = false
let contextMenuHooked = false
let contextMenuUpdateInFlight: Promise<void> | null = null

const MP4_PREFERRED_FORMAT = 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best'
const SITE_STRATEGIES: Array<{
  hosts: string[]
  extractor: 'yt-dlp' | 'aria2'
  format?: string
}> = [
  {
    hosts: ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be'],
    extractor: 'yt-dlp',
    format: MP4_PREFERRED_FORMAT,
  },
]

function clipLog(...args: unknown[]): void {
  console.log('[gomdown-helper][clip][bg]', ...args)
}

function urlFingerprint(raw: string): string {
  try {
    const u = new URL(raw)
    const path = (u.pathname || '/').replace(/\/+$/, '') || '/'
    return `${u.protocol}//${u.host}${path}`.toLowerCase()
  } catch {
    return String(raw || '').toLowerCase()
  }
}

function pruneMap<K>(map: Map<K, number>): void {
  const now = Date.now()
  for (const [key, expiresAt] of map.entries()) {
    if (expiresAt <= now) map.delete(key)
  }
}

function rememberCapturedUrl(url: string): void {
  const expiresAt = Date.now() + REQUEST_TTL_MS
  capturedUrls.set(normalizeUrl(url), expiresAt)
  capturedFingerprints.set(urlFingerprint(url), expiresAt)
}

function rememberCapturedTab(tabId?: number): void {
  if (!Number.isInteger(tabId) || (tabId ?? -1) < 0) return
  capturedTabIds.set(tabId as number, Date.now() + REQUEST_TTL_MS)
}

function wasCapturedUrl(url: string): boolean {
  pruneMap(capturedUrls)
  pruneMap(capturedFingerprints)
  const normalized = normalizeUrl(url)
  return capturedUrls.has(normalized) || capturedFingerprints.has(urlFingerprint(url))
}

function wasCapturedTab(tabId?: number): boolean {
  pruneMap(capturedTabIds)
  if (!Number.isInteger(tabId) || (tabId ?? -1) < 0) return false
  return capturedTabIds.has(tabId as number)
}

function shouldSuppressDuplicateTransfer(url: string): boolean {
  pruneMap(recentTransferFingerprints)
  return recentTransferFingerprints.has(urlFingerprint(url))
}

function rememberRecentTransfer(url: string): void {
  recentTransferFingerprints.set(urlFingerprint(url), Date.now() + TRANSFER_DEDUPE_TTL_MS)
}

function wasRequestHandled(requestId?: string): boolean {
  pruneMap(handledRequestIds)
  return !!requestId && handledRequestIds.has(requestId)
}

function markRequestHandled(requestId?: string): void {
  if (!requestId) return
  handledRequestIds.set(requestId, Date.now() + REQUEST_TTL_MS)
}

function shouldSuppressDuplicateMedia(url: string): boolean {
  pruneMap(recentMediaFingerprints)
  const fp = mediaFingerprint(url)
  return recentMediaFingerprints.has(fp)
}

function rememberMediaFingerprint(url: string): void {
  recentMediaFingerprints.set(mediaFingerprint(url), Date.now() + REQUEST_TTL_MS)
}

async function requestGdownFocus(): Promise<void> {
  try {
    await nativeFocus()
  } catch {
    // ignored
  }
}

async function notify(message: string): Promise<void> {
  await browser.notifications
    .create(`gomdown-notice-${Date.now()}`, {
      type: 'basic',
      iconUrl: '/images/icon-large.png',
      title: 'Gomdown Helper',
      message,
    })
    .catch(() => null)
}

async function transferUrlToGdown(
  url: string,
  referer = '',
  extractor?: 'yt-dlp' | 'aria2',
  format?: string,
  out?: string,
  extra?: { cookie?: string; userAgent?: string; authorization?: string; proxy?: string }
): Promise<{ ok: boolean; error?: string }> {
  if (shouldSuppressDuplicateTransfer(url)) {
    return { ok: false, error: 'duplicate transfer suppressed' }
  }

  const settings = await getSettings()
  if (!settings.extensionStatus) return { ok: false, error: 'extension disabled' }
  if (!settings.motrixAPIkey) return { ok: false, error: 'motrixAPIkey is not set' }

  try {
    const nativeResult = await nativeAddUri({
      url,
      rpcPort: settings.motrixPort,
      rpcSecret: settings.motrixAPIkey,
      referer,
      split: 64,
      out: out?.trim() || undefined,
      cookie: extra?.cookie?.trim() || undefined,
      userAgent: extra?.userAgent?.trim() || undefined,
      authorization: extra?.authorization?.trim() || undefined,
      proxy: extra?.proxy?.trim() || undefined,
      extractor: extractor === 'yt-dlp' ? 'yt-dlp' : undefined,
      format: extractor === 'yt-dlp' ? format || MP4_PREFERRED_FORMAT : undefined,
    })

    if (!nativeResult?.ok) {
      return { ok: false, error: nativeResult?.error || 'native host addUri failed' }
    }

    if (settings.activateAppOnDownload) {
      await requestGdownFocus()
    }
    rememberRecentTransfer(url)
    const gid = String(nativeResult?.gid || nativeResult?.requestId || `pending-${Date.now()}`)
    const pathname = (() => {
      try {
        return new URL(url).pathname
      } catch {
        return ''
      }
    })()
    const guessedName = pathname.split('/').filter(Boolean).pop() || url
    await upsertHistory({
      gid,
      downloader: 'native',
      startTime: new Date().toISOString(),
      icon: '/images/32.png',
      name: decodeURIComponent(guessedName),
      path: null,
      status: nativeResult?.pending ? 'queued' : 'downloading',
      size: 0,
      downloaded: 0,
    })

    if (settings.enableNotifications) {
      await browser.notifications.create(`gomdown-transfer-${Date.now()}`, {
        type: 'basic',
        iconUrl: '/images/icon-large.png',
        title: 'Gomdown Helper',
        message: 'Download sent to gdown',
      })
    }

    return { ok: true }
  } catch (error) {
    return { ok: false, error: String(error) }
  }
}

function hostOf(raw: string): string {
  try {
    return new URL(raw).hostname.toLowerCase()
  } catch {
    return ''
  }
}

function resolveStrategy(
  url: string,
  referer = '',
  kind = ''
): { extractor: 'yt-dlp' | 'aria2'; format?: string } {
  const candidates = [hostOf(url), hostOf(referer)].filter(Boolean)
  for (const rule of SITE_STRATEGIES) {
    if (candidates.some((host) => rule.hosts.includes(host))) {
      return { extractor: rule.extractor, format: rule.format }
    }
  }

  const mediaKind = kind.toLowerCase()
  if (mediaKind === 'm3u8' || mediaKind === 'm3u' || mediaKind === 'hls') {
    return { extractor: 'yt-dlp', format: 'best' }
  }
  if (mediaKind === 'mp4') {
    return { extractor: 'aria2' }
  }
  return { extractor: 'aria2' }
}

function getHeaderFromRequestHeaders(headers: any[], name: string): string {
  const lower = name.toLowerCase()
  const found = headers.find((item: any) => String(item?.name || '').toLowerCase() === lower)
  return String(found?.value || '').trim()
}

async function shouldCaptureRequest(details: any): Promise<boolean> {
  if (details.type !== 'main_frame') return false
  if ((details.method || '').toUpperCase() !== 'GET') return false
  if (typeof details.statusCode === 'number' && (details.statusCode < 200 || details.statusCode > 299)) return false

  const settings = await getSettings()
  if (!settings.extensionStatus || !settings.motrixAPIkey) return false
  const contentLengthRaw = String(
    Array.isArray(details?.responseHeaders)
      ? details.responseHeaders.find((h: any) => String(h?.name || '').toLowerCase() === 'content-length')?.value || ''
      : ''
  )
  const contentLength = Number(contentLengthRaw || 0)
  if (settings.minFileSize > 0 && contentLength > 0 && contentLength < settings.minFileSize * 1024 * 1024) {
    return false
  }
  return isLikelyDownloadResponse(details)
}

async function interceptByWebRequest(details: any): Promise<void> {
  if (wasRequestHandled(details.requestId)) return

  const accepted = await shouldCaptureRequest(details)
  if (!accepted) return

  const req = pendingRequests.get(details.requestId)
  const referer = String(req?.documentUrl || req?.originUrl || req?.initiator || req?.url || '')
  const sent = await transferUrlToGdown(details.url, referer)
  if (!sent.ok) return

  markRequestHandled(details.requestId)
  rememberCapturedUrl(details.url)
  rememberCapturedTab(details.tabId)
}

async function interceptMediaByWebRequest(details: any): Promise<void> {
  if (!shouldCaptureMediaResponse(details)) return
  const settings = await getSettings()
  if (!settings.extensionStatus) return
  if (shouldSuppressDuplicateMedia(details.url)) return

  const req = pendingRequests.get(details.requestId)
  const referer = String(req?.documentUrl || req?.originUrl || req?.initiator || req?.url || '')
  const requestHeaders = Array.isArray(req?.requestHeaders) ? req.requestHeaders : []
  const cookie = getHeaderFromRequestHeaders(requestHeaders, 'cookie')
  const userAgent = getHeaderFromRequestHeaders(requestHeaders, 'user-agent')
  const candidate = makeMediaCandidate(details, referer)
  let pageTitle = ''
  if (candidate.tabId >= 0) {
    const tab = await browser.tabs.get(candidate.tabId).catch(() => null)
    pageTitle = String(tab?.title || '').trim()
  }
  const suggestedOut = suggestMediaOut(candidate.url, candidate.kind, pageTitle)
  const enriched = {
    ...candidate,
    pageTitle: pageTitle || undefined,
    cookie: cookie || undefined,
    userAgent: userAgent || undefined,
    suggestedOut: suggestedOut || undefined,
  }
  await upsertMediaCandidate(enriched, mediaFingerprint(enriched.url))
  rememberMediaFingerprint(enriched.url)
  if (enriched.tabId >= 0) {
    await browser.tabs
      .sendMessage(enriched.tabId, {
        type: 'media:captured',
        kind: enriched.kind,
        url: enriched.url,
        suggestedOut: enriched.suggestedOut || '',
      })
      .catch(() => null)
  }
}

function sanitizeOut(value: string): string {
  let next = value
    .trim()
    .replace(/[\\/:*?"<>|]/g, '_')
    .replace(/\s+/g, ' ')
    .replace(/^\.+/, '')
    .replace(/\.+$/, '')
  if (next.length > 180) next = next.slice(0, 180).trim()
  return next
}

function extFromUrl(url: string): string {
  try {
    const path = new URL(url).pathname.toLowerCase()
    const match = path.match(/\.([a-z0-9]{2,6})(?:$|[?#])/)
    return match?.[1] || ''
  } catch {
    return ''
  }
}

function suggestMediaOut(url: string, kind: string, pageTitle: string): string {
  const title = sanitizeOut(pageTitle || '')
  const ext = extFromUrl(url)
  const baseExt = ext || (kind === 'mp4' ? 'mp4' : kind === 'm3u8' || kind === 'm3u' || kind === 'hls' ? 'mp4' : '')
  if (!title) return ''
  if (!baseExt) return title
  if (title.toLowerCase().endsWith(`.${baseExt}`)) return title
  return `${title}.${baseExt}`
}

async function applyShelfVisibility(): Promise<void> {
  const downloadsApi = browser.downloads as any
  if (!downloadsApi.setShelfEnabled) return
  const settings = await getSettings()
  if (!settings.extensionStatus) return
  const enabled = settings.useNativeHost ? false : !settings.hideChromeBar
  await downloadsApi.setShelfEnabled(enabled)
}

function setupDownloadCancelHook(): void {
  if (downloadHooked) return
  downloadHooked = true
  browser.downloads.onCreated.addListener(async (downloadItem) => {
    await applyShelfVisibility()
    const download = downloadItem as any
    const target = download.finalUrl || download.url || ''
    if (!wasCapturedUrl(target) && !wasCapturedTab(download.tabId)) return

    await browser.downloads.cancel(downloadItem.id).catch(() => null)
    await browser.downloads.erase({ id: downloadItem.id }).catch(() => null)
    await browser.downloads.removeFile(downloadItem.id).catch(() => null)
  })
}

function setupWebRequestInterceptor(): void {
  if (webRequestHooked) return
  webRequestHooked = true
  browser.webRequest.onSendHeaders.addListener(
    (details) => {
      pendingRequests.set(details.requestId, details as any)
    },
    { urls: ['<all_urls>'] },
    ['requestHeaders', 'extraHeaders']
  )

  browser.webRequest.onErrorOccurred.addListener(
    (details) => {
      pendingRequests.delete(details.requestId)
      handledRequestIds.delete(String(details.requestId))
    },
    { urls: ['<all_urls>'] }
  )

  browser.webRequest.onCompleted.addListener(
    (details) => {
      pendingRequests.delete(details.requestId)
      handledRequestIds.delete(String(details.requestId))
    },
    { urls: ['<all_urls>'] }
  )

  browser.webRequest.onHeadersReceived.addListener(
    (details) => {
      void interceptByWebRequest(details)
      void interceptMediaByWebRequest(details)
    },
    { urls: ['<all_urls>'] },
    ['responseHeaders']
  )
}

async function handleContextMenuClick(data: any, tab?: any): Promise<void> {
  console.log('[gomdown-helper] context menu clicked', {
    menuItemId: data?.menuItemId,
    linkUrl: data?.linkUrl,
    srcUrl: data?.srcUrl,
    frameUrl: data?.frameUrl,
    pageUrl: data?.pageUrl,
    tabUrl: tab?.url,
  })
  const clickedId = data?.menuItemId
  if (clickedId != null && String(clickedId) !== contextMenuId) return

  const linkUrl = String(data?.linkUrl || data?.srcUrl || '').trim()
  const pageUrl = String(data?.frameUrl || data?.pageUrl || tab?.url || '').trim()
  const targetUrl = linkUrl || pageUrl

  const url = String(targetUrl || '').trim()
  if (!url || /^(about:|chrome:|chrome-extension:|edge:|brave:)/i.test(url)) {
    await notify('다운로드 가능한 URL을 찾지 못했습니다.')
    return
  }

  const strategy = resolveStrategy(url, String(data?.pageUrl || tab?.url || ''), '')
  const result = await transferUrlToGdown(
    url,
    String(data?.pageUrl || tab?.url || ''),
    strategy.extractor,
    strategy.format
  )
  if (!result.ok) {
    await notify(`전송 실패: ${result.error || 'unknown error'}`)
    return
  }
  await notify(strategy.extractor === 'yt-dlp' ? '페이지 URL을 yt-dlp로 gdown에 전송했습니다.' : 'gdown으로 전송했습니다.')
}

function createContextMenuSafe(): void {
  if (typeof chrome === 'undefined' || !chrome.contextMenus?.create) {
    browser.contextMenus.create({
      id: contextMenuId,
      title: 'Download with Gomdown',
      visible: true,
      contexts: ['all'],
    })
    return
  }
  chrome.contextMenus.create(
    {
      id: contextMenuId,
      title: 'Download with Gomdown',
      contexts: ['all'],
    },
    () => {
      void chrome.runtime.lastError
    }
  )
}

function setupContextMenuClickListener(): void {
  if (contextMenuHooked) return
  if (typeof chrome !== 'undefined' && chrome.contextMenus?.onClicked) {
    chrome.contextMenus.onClicked.addListener((info, tab) => {
      void handleContextMenuClick(info, tab)
    })
  } else {
    browser.contextMenus.onClicked.addListener((info: any, tab: any) => {
      void handleContextMenuClick(info, tab)
    })
  }
  contextMenuHooked = true
}

async function createMenuItemInternal(): Promise<void> {
  const settings = await getSettings()
  if (!settings.extensionStatus || !settings.showContextOption) {
    await browser.contextMenus.removeAll().catch(() => null)
    return
  }

  await browser.contextMenus.removeAll().catch(() => null)
  createContextMenuSafe()
}

function createMenuItem(): Promise<void> {
  if (contextMenuUpdateInFlight) return contextMenuUpdateInFlight
  contextMenuUpdateInFlight = createMenuItemInternal().finally(() => {
    contextMenuUpdateInFlight = null
  })
  return contextMenuUpdateInFlight
}

async function requestCreateClipOnActiveTab(): Promise<{ ok: boolean; error?: string }> {
  clipLog('requestCreateClipOnActiveTab:start')
  const tabs = await browser.tabs.query({ active: true, currentWindow: true })
  const tab = tabs[0]
  const tabId = Number(tab?.id)
  clipLog('active tab', {
    tabId,
    url: String(tab?.url || ''),
    title: String(tab?.title || ''),
  })
  if (!Number.isInteger(tabId) || tabId < 0) {
    clipLog('requestCreateClipOnActiveTab:fail', 'active tab is unavailable')
    return { ok: false, error: 'active tab is unavailable' }
  }

  const sendClipCreateMessage = async (): Promise<{ ok?: boolean; error?: string } | undefined> => {
    return (await browser.tabs.sendMessage(tabId, {
      type: 'clip:create-from-selection',
    })) as { ok?: boolean; error?: string } | undefined
  }

  try {
    let result: { ok?: boolean; error?: string } | undefined
    try {
      result = await sendClipCreateMessage()
    } catch (firstError) {
      clipLog('first sendMessage failed, using scripting fallback', String(firstError))
      throw firstError
    }
    clipLog('content response', result)
    if (result?.ok) return { ok: true }
    clipLog('requestCreateClipOnActiveTab:fail', result?.error || 'failed to create clip in active tab')
    return { ok: false, error: result?.error || 'failed to create clip in active tab' }
  } catch (error) {
    clipLog('requestCreateClipOnActiveTab:exception', String(error))
    const scripted = await createClipFromSelectionByScriptingFallback(tabId, {
      url: String(tab?.url || ''),
      title: String(tab?.title || ''),
    }).catch((fallbackError) => {
      clipLog('scripting fallback exception', String(fallbackError))
      return { ok: false, error: String(fallbackError) }
    })
    if (scripted.ok) return { ok: true }
    return { ok: false, error: scripted.error || 'content script is not ready on active tab' }
  }
}

async function showInlineActionBarByScripting(tabId: number): Promise<boolean> {
  try {
    const result = await browser.scripting.executeScript({
      target: { tabId },
      func: () => {
        const rootId = 'gomdown-inline-actionbar-fallback'
        const existing = document.getElementById(rootId)
        if (existing) {
          existing.style.display = 'flex'
          return { ok: true }
        }

        const root = document.createElement('div')
        root.id = rootId
        root.style.position = 'fixed'
        root.style.left = '50%'
        root.style.bottom = '14px'
        root.style.transform = 'translateX(-50%)'
        root.style.zIndex = '2147483647'
        root.style.display = 'flex'
        root.style.gap = '8px'
        root.style.padding = '10px'
        root.style.background = 'rgba(15, 20, 31, 0.94)'
        root.style.border = '1px solid rgba(97, 112, 155, 0.52)'
        root.style.borderRadius = '12px'
        root.style.boxShadow = '0 12px 24px rgba(0, 0, 0, 0.3)'
        root.style.fontFamily = 'ui-sans-serif, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif'

        const status = document.createElement('div')
        status.textContent = 'Gomdown Quick Action'
        status.style.fontSize = '11px'
        status.style.color = '#c9d4f2'
        status.style.display = 'flex'
        status.style.alignItems = 'center'
        status.style.padding = '0 2px'

        const mk = (label: string, primary = false): HTMLButtonElement => {
          const btn = document.createElement('button')
          btn.type = 'button'
          btn.textContent = label
          btn.style.height = '32px'
          btn.style.padding = '0 12px'
          btn.style.borderRadius = '8px'
          btn.style.border = primary ? '1px solid #5c6cf3' : '1px solid #4b5873'
          btn.style.background = primary ? '#5c6cf3' : '#2a3346'
          btn.style.color = '#e8edff'
          btn.style.fontSize = '12px'
          btn.style.fontWeight = '700'
          btn.style.cursor = 'pointer'
          return btn
        }

        const clipBtn = mk('클립 저장', true)
        const pageBtn = mk('현재 페이지')
        const mdBtn = mk('MD')
        const jsonBtn = mk('JSON')
        const obsidianBtn = mk('Obsidian')
        const closeBtn = mk('닫기')
        const extras = document.createElement('div')
        extras.style.display = 'none'
        extras.style.gap = '6px'
        extras.style.alignItems = 'center'
        extras.style.flexWrap = 'wrap'
        extras.appendChild(mdBtn)
        extras.appendChild(jsonBtn)
        extras.appendChild(obsidianBtn)

        clipBtn.onclick = () => {
          try {
            chrome.runtime.sendMessage({ type: 'clip:create-active-tab' }, (response) => {
              const ok = Boolean(response?.ok)
              status.textContent = ok ? '클립 저장 완료' : `실패: ${String(response?.error || 'unknown error')}`
              status.style.color = ok ? '#8ff0a4' : '#ffaaaa'
              if (ok) extras.style.display = 'flex'
            })
          } catch (error) {
            status.textContent = `실패: ${String(error)}`
            status.style.color = '#ffaaaa'
          }
        }

        pageBtn.onclick = () => {
          try {
            chrome.runtime.sendMessage(
              {
                type: 'page:enqueue-ytdlp-url',
                url: window.location.href,
                referer: window.location.href,
              },
              (response) => {
                const ok = Boolean(response?.ok)
                status.textContent = ok ? '현재 페이지 전송 완료' : `실패: ${String(response?.error || 'unknown error')}`
                status.style.color = ok ? '#8ff0a4' : '#ffaaaa'
              }
            )
          } catch (error) {
            status.textContent = `실패: ${String(error)}`
            status.style.color = '#ffaaaa'
          }
        }

        mdBtn.onclick = () => {
          try {
            chrome.runtime.sendMessage(
              {
                type: 'clip:export-current-page-md',
                pageUrl: window.location.href,
                pageTitle: document.title || window.location.href,
              },
              (response) => {
                const ok = Boolean(response?.ok)
                status.textContent = ok ? 'MD 내보내기 완료' : `실패: ${String(response?.error || 'unknown error')}`
                status.style.color = ok ? '#8ff0a4' : '#ffaaaa'
              }
            )
          } catch (error) {
            status.textContent = `실패: ${String(error)}`
            status.style.color = '#ffaaaa'
          }
        }

        jsonBtn.onclick = () => {
          try {
            chrome.runtime.sendMessage(
              {
                type: 'clip:export-current-page-json',
                pageUrl: window.location.href,
                pageTitle: document.title || window.location.href,
              },
              (response) => {
                const ok = Boolean(response?.ok)
                status.textContent = ok ? 'JSON 내보내기 완료' : `실패: ${String(response?.error || 'unknown error')}`
                status.style.color = ok ? '#8ff0a4' : '#ffaaaa'
              }
            )
          } catch (error) {
            status.textContent = `실패: ${String(error)}`
            status.style.color = '#ffaaaa'
          }
        }

        obsidianBtn.onclick = () => {
          try {
            chrome.runtime.sendMessage(
              {
                type: 'clip:send-obsidian-current-page',
                pageUrl: window.location.href,
                pageTitle: document.title || window.location.href,
              },
              (response) => {
                const ok = Boolean(response?.ok && response?.uri)
                if (ok) {
                  try {
                    window.open(String(response.uri), '_blank')
                  } catch {
                    window.location.href = String(response.uri)
                  }
                  status.textContent = 'Obsidian 전송 시도 완료'
                  status.style.color = '#8ff0a4'
                  return
                }
                status.textContent = `실패: ${String(response?.error || 'unknown error')}`
                status.style.color = '#ffaaaa'
              }
            )
          } catch (error) {
            status.textContent = `실패: ${String(error)}`
            status.style.color = '#ffaaaa'
          }
        }

        closeBtn.onclick = () => {
          root.remove()
        }

        root.appendChild(clipBtn)
        root.appendChild(pageBtn)
        root.appendChild(closeBtn)
        root.appendChild(extras)
        root.appendChild(status)
        document.documentElement.appendChild(root)
        window.setTimeout(() => {
          root.remove()
        }, 9000)
        return { ok: true }
      },
    })
    return Boolean((result?.[0]?.result as { ok?: boolean } | undefined)?.ok)
  } catch {
    return false
  }
}

function makeClipId(): string {
  try {
    return crypto.randomUUID()
  } catch {
    return `clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
  }
}

function clipToMarkdownBlock(item: ClipItem, index: number): string {
  const quoteRaw = String(item.quote || item.anchor?.exact || '').replace(/\r\n/g, '\n').trim()
  const quote = quoteRaw.length > 4000 ? `${quoteRaw.slice(0, 4000)}\n...(truncated)` : quoteRaw
  const lines = quote
    .split('\n')
    .map((line) => line.trimEnd())
    .filter((line, idx, arr) => !(line === '' && arr[idx - 1] === ''))
  const block = lines.length === 0 ? '> ' : lines.map((line) => `> ${line}`).join('\n')
  return [
    `### ${index + 1}. Clip`,
    block,
    '',
    `- created: ${item.createdAt}`,
    `- status: ${item.resolveStatus || 'ok'}`,
  ].join('\n')
}

function buildPageMarkdown(items: ClipItem[], pageUrl: string, pageTitle = ''): string {
  const title = pageTitle || items[0]?.pageTitle || pageUrl || 'Untitled'
  const lines: string[] = [
    `# ${title}`,
    `- source: ${pageUrl}`,
    `- exportedAt: ${new Date().toISOString()}`,
    `- clips: ${items.length}`,
    '',
    '---',
    '',
  ]
  for (let i = 0; i < items.length; i += 1) {
    lines.push(clipToMarkdownBlock(items[i], i))
    lines.push('')
  }
  return lines.join('\n')
}

function safeFileName(raw: string): string {
  const title = String(raw || '')
    .replace(/\s*[|\-–—]\s*(qiita|medium|youtube|x|twitter|tistory|velog|github)\s*$/i, '')
    .replace(/\s*[-–—|]\s*(edge|chrome|firefox)\s*$/i, '')
    .replace(/\[[^\]]{1,30}\]\s*$/g, '')
  return title
    .trim()
    .replace(/[\\/:*?"<>|]/g, '_')
    .replace(/[(){}[\]]/g, '')
    .replace(/\s+/g, ' ')
    .replace(/[._-]{2,}/g, '-')
    .replace(/^[._\-\s]+|[._\-\s]+$/g, '')
    .slice(0, 90) || 'clips'
}

function normalizeObsidianTarget(vaultRaw: string, folderRaw: string): { vault: string; folder: string } {
  let vault = String(vaultRaw || '').trim()
  let folder = String(folderRaw || '').trim()

  // If user entered a filesystem path, use its basename as vault name.
  if (vault.includes('/') || vault.includes('\\')) {
    const parts = vault.split(/[\\/]+/).filter(Boolean)
    vault = parts[parts.length - 1] || ''
  }

  // Ignore common placeholder values so we can fall back to default vault.
  if (/^myvault$/i.test(vault) || /^example$/i.test(vault)) {
    vault = ''
  }

  folder = folder.replace(/^\/+|\/+$/g, '')
  return { vault, folder }
}

async function readLastObsidianVault(): Promise<string> {
  try {
    const raw = await browser.storage.local.get(OBSIDIAN_LAST_VAULT_KEY)
    const value = String(raw?.[OBSIDIAN_LAST_VAULT_KEY] || '').trim()
    return value
  } catch {
    return ''
  }
}

async function writeLastObsidianVault(vaultRaw: string): Promise<void> {
  const vault = String(vaultRaw || '').trim()
  if (!vault) return
  try {
    await browser.storage.local.set({
      [OBSIDIAN_LAST_VAULT_KEY]: vault,
    })
  } catch {
    // ignored
  }
}

async function downloadTextFile(filename: string, mime: string, content: string): Promise<{ ok: boolean; error?: string; downloadId?: number }> {
  if (!filename.trim()) return { ok: false, error: 'filename is empty' }
  const url = `data:${mime},${encodeURIComponent(content)}`
  try {
    const downloadId = await browser.downloads.download({
      url,
      filename: filename.trim(),
      saveAs: true,
    })
    return { ok: true, downloadId }
  } catch (error) {
    return { ok: false, error: String(error) }
  }
}

async function exportCurrentPageClipsAs(
  pageUrlRaw: string,
  pageTitleRaw: string,
  kind: 'md' | 'json'
): Promise<{ ok: boolean; error?: string; count?: number; downloadId?: number }> {
  const pageUrl = normalizePageUrl(String(pageUrlRaw || '').trim())
  if (!pageUrl) return { ok: false, error: 'pageUrl is empty' }
  const items = await listClipsByUrl(pageUrl)
  if (items.length === 0) return { ok: false, error: 'no clips for current page' }

  const pageTitle = String(pageTitleRaw || items[0]?.pageTitle || pageUrl).trim()
  if (kind === 'md') {
    const markdown = buildPageMarkdown(items, pageUrl, pageTitle)
    const filename = `${safeFileName(pageTitle)}-clips.md`
    const saved = await downloadTextFile(filename, 'text/markdown;charset=utf-8', markdown)
    return { ok: saved.ok, error: saved.error, count: items.length, downloadId: saved.downloadId }
  }

  const payload = {
    exportedAt: new Date().toISOString(),
    version: 1,
    count: items.length,
    clips: items,
  }
  const filename = `${safeFileName(pageTitle)}-clips.json`
  const saved = await downloadTextFile(filename, 'application/json;charset=utf-8', JSON.stringify(payload, null, 2))
  return { ok: saved.ok, error: saved.error, count: items.length, downloadId: saved.downloadId }
}

async function buildObsidianUriForCurrentPage(pageUrlRaw: string, pageTitleRaw: string): Promise<{ ok: boolean; error?: string; uri?: string; count?: number }> {
  const pageUrl = normalizePageUrl(String(pageUrlRaw || '').trim())
  if (!pageUrl) return { ok: false, error: 'pageUrl is empty' }
  const items = await listClipsByUrl(pageUrl)
  if (items.length === 0) return { ok: false, error: 'no clips for current page' }
  const settings = await getSettings()
  const normalizedTarget = normalizeObsidianTarget(settings.obsidianVault, settings.obsidianFolder)
  if (normalizedTarget.vault) {
    await writeLastObsidianVault(normalizedTarget.vault)
  }
  const vault = normalizedTarget.vault || (await readLastObsidianVault())
  const pageTitle = String(pageTitleRaw || items[0]?.pageTitle || pageUrl).trim()
  const markdown = buildPageMarkdown(items, pageUrl, pageTitle)
  const folder = normalizedTarget.folder
  const noteBase = `${safeFileName(pageTitle)}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}`
  const filePath = folder ? `${folder}/${noteBase}` : noteBase
  const query =
    `file=${encodeURIComponent(filePath)}` +
    `&content=${encodeURIComponent(markdown)}`
  const uri = vault
    ? `obsidian://new?vault=${encodeURIComponent(vault)}&${query}`
    : `obsidian://new?${query}`
  return { ok: true, uri, count: items.length }
}

async function revealClipByScriptingFallback(tabId: number, item: ClipItem): Promise<boolean> {
  try {
    const result = await browser.scripting.executeScript({
      target: { tabId },
      args: [item.quote || item.anchor?.exact || '', item.anchor?.prefix || '', item.anchor?.suffix || ''],
      func: (quoteRaw: string, prefixRaw: string, suffixRaw: string) => {
        const quote = String(quoteRaw || '').trim()
        if (!quote) return { ok: false, error: 'quote is empty' }
        const prefix = String(prefixRaw || '').trim()
        const suffix = String(suffixRaw || '').trim()
        const full = document.body?.innerText || document.documentElement?.innerText || ''
        if (!full) return { ok: false, error: 'document text is empty' }

        let idx = full.indexOf(quote)
        let matchedIndex = -1
        while (idx >= 0) {
          const left = prefix ? full.slice(Math.max(0, idx - prefix.length), idx).trim() : ''
          const right = suffix ? full.slice(idx + quote.length, idx + quote.length + suffix.length).trim() : ''
          const prefixOk = !prefix || left === prefix
          const suffixOk = !suffix || right === suffix
          if (prefixOk && suffixOk) {
            matchedIndex = idx
            break
          }
          idx = full.indexOf(quote, idx + Math.max(1, Math.floor(quote.length / 2)))
        }
        if (matchedIndex < 0) return { ok: false, error: 'quote not found in page text' }

        const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT)
        let cursor = 0
        let startNode: Text | null = null
        let endNode: Text | null = null
        let startOffset = 0
        let endOffset = 0
        const targetStart = matchedIndex
        const targetEnd = matchedIndex + quote.length
        let current = walker.nextNode()
        while (current) {
          if (current.nodeType === Node.TEXT_NODE) {
            const text = current as Text
            const value = text.nodeValue || ''
            const next = cursor + value.length
            if (!startNode && targetStart >= cursor && targetStart <= next) {
              startNode = text
              startOffset = Math.max(0, targetStart - cursor)
            }
            if (!endNode && targetEnd >= cursor && targetEnd <= next) {
              endNode = text
              endOffset = Math.max(0, targetEnd - cursor)
            }
            cursor = next
          }
          current = walker.nextNode()
        }
        if (!startNode || !endNode) return { ok: false, error: 'failed to map quote to text nodes' }

        const range = document.createRange()
        range.setStart(startNode, Math.min(startNode.length, startOffset))
        range.setEnd(endNode, Math.min(endNode.length, endOffset))
        const rect = range.getBoundingClientRect()
        const marker = document.createElement('span')
        marker.style.position = 'absolute'
        marker.style.left = `${window.scrollX + rect.left - 2}px`
        marker.style.top = `${window.scrollY + rect.top - 2}px`
        marker.style.width = `${Math.max(8, rect.width + 4)}px`
        marker.style.height = `${Math.max(14, rect.height + 4)}px`
        marker.style.pointerEvents = 'none'
        marker.style.borderRadius = '6px'
        marker.style.background = 'rgba(255, 240, 130, 0.42)'
        marker.style.border = '1px solid rgba(230, 190, 70, 0.72)'
        marker.style.zIndex = '2147483647'
        document.documentElement.appendChild(marker)
        window.scrollTo({
          top: Math.max(0, window.scrollY + rect.top - window.innerHeight * 0.35),
          behavior: 'smooth',
        })
        window.setTimeout(() => marker.remove(), 1800)
        return { ok: true }
      },
    })
    return Boolean((result?.[0]?.result as { ok?: boolean } | undefined)?.ok)
  } catch {
    return false
  }
}

async function createClipFromSelectionByScriptingFallback(
  tabId: number,
  fallbackTab?: { url?: string; title?: string }
): Promise<{ ok: boolean; item?: ClipItem; error?: string }> {
  clipLog('createClipFromSelectionByScriptingFallback:start', { tabId })
  const injected = await browser.scripting.executeScript({
    target: { tabId },
    func: () => {
      const selection = window.getSelection()
      if (!selection || selection.rangeCount === 0) return { ok: false, error: 'empty selection' }
      const quote = String(selection.toString() || '').trim()
      if (!quote) return { ok: false, error: 'empty selection' }
      const range = selection.getRangeAt(0)
      const startNode = range.startContainer
      const endNode = range.endContainer
      const startText = startNode.nodeType === Node.TEXT_NODE ? String((startNode as Text).nodeValue || '') : ''
      const endText = endNode.nodeType === Node.TEXT_NODE ? String((endNode as Text).nodeValue || '') : ''
      const prefix = startText ? startText.slice(Math.max(0, range.startOffset - 24), range.startOffset).trim() : ''
      const suffix = endText ? endText.slice(range.endOffset, Math.min(endText.length, range.endOffset + 24)).trim() : ''
      return {
        ok: true,
        quote,
        quoteHtml: (() => {
          try {
            const div = document.createElement('div')
            div.appendChild(range.cloneContents())
            return div.innerHTML.trim()
          } catch {
            return ''
          }
        })(),
        pageUrl: String(location.href || '').split('#')[0],
        pageTitle: String(document.title || ''),
        anchor: {
          exact: quote,
          prefix: prefix || undefined,
          suffix: suffix || undefined,
        },
      }
    },
  })

  const payload = injected?.[0]?.result as
    | {
        ok?: boolean
        error?: string
        quote?: string
        quoteHtml?: string
        pageUrl?: string
        pageTitle?: string
        anchor?: ClipItem['anchor']
      }
    | undefined
  if (!payload?.ok) {
    const error = payload?.error || 'selection capture fallback failed'
    clipLog('createClipFromSelectionByScriptingFallback:fail', error)
    return { ok: false, error }
  }

  const pageUrl = normalizePageUrl(String(payload.pageUrl || fallbackTab?.url || ''))
  const pageTitle = String(payload.pageTitle || fallbackTab?.title || pageUrl).trim()
  const quote = String(payload.quote || payload.anchor?.exact || '').trim()
  const anchor = (payload.anchor || { exact: quote }) as ClipItem['anchor']
  if (!pageUrl || !quote || !String(anchor?.exact || '').trim()) {
    return { ok: false, error: 'invalid clip payload from fallback' }
  }

  const created: ClipItem = {
    id: makeClipId(),
    tabId,
    pageUrl,
    pageTitle: pageTitle || pageUrl,
    quote,
    quoteHtml: String(payload.quoteHtml || '').trim() || undefined,
    createdAt: new Date().toISOString(),
    color: 'yellow',
    anchor,
  }
  const item = await insertClip(created)
  clipLog('createClipFromSelectionByScriptingFallback:ok', { id: item.id, pageUrl: item.pageUrl })
  return { ok: true, item }
}

async function findExistingTabIdByUrl(url: string): Promise<number | null> {
  const target = normalizePageUrl(url)
  const tabs = await browser.tabs.query({})
  const hit = tabs.find((tab) => normalizePageUrl(String(tab.url || '')) === target)
  return Number.isInteger(hit?.id) ? (hit?.id as number) : null
}

browser.runtime.onMessage.addListener((message: any, sender: any) => {
  if (message?.type?.startsWith?.('clip:')) {
    clipLog('runtime.onMessage', message?.type, {
      senderTabId: Number(sender?.tab?.id),
      senderUrl: String(sender?.tab?.url || ''),
    })
  }

  if (message?.type === 'capture-link-download') {
    const url = String(message?.url || '').trim()
    if (!url) return Promise.resolve({ ok: false, error: 'url is empty' })

    const senderTabId = Number(sender?.tab?.id)
    return transferUrlToGdown(url, String(message?.referer || '')).then((result) => {
      if (result.ok) {
        rememberCapturedUrl(url)
        rememberCapturedTab(senderTabId)
      }
      return result
    })
  }

  if (message?.type === 'media:list') {
    return listMediaCandidates().then((items) => ({ ok: true, items }))
  }

  if (message?.type === 'media:clear') {
    return clearMediaCandidates().then(() => ({ ok: true }))
  }

  if (message?.type === 'media:enqueue') {
    const url = String(message?.url || '').trim()
    const kind = String(message?.kind || '').trim()
    const suggestedOut = String(message?.suggestedOut || '').trim()
    const referer = String(message?.referer || '').trim()
    const cookie = String(message?.cookie || '').trim()
    const userAgent = String(message?.userAgent || '').trim()
    if (!url) return Promise.resolve({ ok: false, error: 'url is empty' })
    const strategy = resolveStrategy(url, referer, kind)
    return transferUrlToGdown(url, referer, strategy.extractor, strategy.format, suggestedOut, {
      cookie,
      userAgent,
    }).then((result) => result)
  }

  if (message?.type === 'page:enqueue-ytdlp') {
    return browser.tabs
      .query({ active: true, currentWindow: true })
      .then(async (tabs) => {
        const tab = tabs[0]
        const url = String(tab?.url || '').trim()
        if (!url) return { ok: false, error: 'active tab url is empty' }
        return transferUrlToGdown(url, url, 'yt-dlp')
      })
  }

  if (message?.type === 'page:enqueue-ytdlp-url') {
    const url = String(message?.url || '').trim()
    const referer = String(message?.referer || url).trim()
    if (!url) return Promise.resolve({ ok: false, error: 'url is empty' })
    return transferUrlToGdown(url, referer || url, 'yt-dlp')
  }

  if (message?.type === 'file:download-text') {
    const filename = String(message?.filename || '').trim()
    const mime = String(message?.mime || 'text/plain;charset=utf-8').trim()
    const content = String(message?.content || '')
    return downloadTextFile(filename, mime, content)
  }

  if (message?.type === 'clip:export-current-page-md') {
    return exportCurrentPageClipsAs(String(message?.pageUrl || ''), String(message?.pageTitle || ''), 'md')
  }

  if (message?.type === 'clip:export-current-page-json') {
    return exportCurrentPageClipsAs(String(message?.pageUrl || ''), String(message?.pageTitle || ''), 'json')
  }

  if (message?.type === 'clip:send-obsidian-current-page') {
    return buildObsidianUriForCurrentPage(String(message?.pageUrl || ''), String(message?.pageTitle || ''))
  }

  if (message?.type === 'clip:create') {
    return getSettings().then(async (settings) => {
      if (!settings.extensionStatus) return { ok: false, error: 'extension disabled' }
      const pageUrl = normalizePageUrl(String(message?.pageUrl || sender?.tab?.url || ''))
      const pageTitle = String(message?.pageTitle || sender?.tab?.title || '').trim()
      const quote = String(message?.quote || message?.anchor?.exact || '').trim()
      const quoteHtml = String(message?.quoteHtml || '').trim()
      const anchor = message?.anchor as ClipItem['anchor']
      if (!pageUrl) return { ok: false, error: 'pageUrl is empty' }
      if (!anchor || !String(anchor.exact || '').trim()) return { ok: false, error: 'anchor is empty' }

      const created: ClipItem = {
        id: makeClipId(),
        tabId: Number.isInteger(sender?.tab?.id) ? Number(sender.tab.id) : undefined,
        pageUrl,
        pageTitle: pageTitle || pageUrl,
        quote: quote || String(anchor.exact || '').trim(),
        quoteHtml: quoteHtml || undefined,
        createdAt: new Date().toISOString(),
        color: 'yellow',
        anchor,
      }
      const item = await insertClip(created)
      return { ok: true, item }
    })
  }

  if (message?.type === 'clip:list') {
    const pageUrl = String(message?.pageUrl || '').trim()
    if (pageUrl) {
      return listClipsByUrl(pageUrl).then((items) => ({ ok: true, items }))
    }
    return listClips().then((items) => ({ ok: true, items }))
  }

  if (message?.type === 'clip:export') {
    return listClips().then((items) => ({ ok: true, items }))
  }

  if (message?.type === 'clip:import') {
    const items = Array.isArray(message?.items) ? message.items : []
    return importClips(items).then((result) => ({ ok: true, ...result }))
  }

  if (message?.type === 'clip:delete') {
    const id = String(message?.id || '').trim()
    if (!id) return Promise.resolve({ ok: false, error: 'id is empty' })
    return deleteClip(id).then((deleted) => ({ ok: deleted }))
  }

  if (message?.type === 'clip:create-active-tab') {
    return requestCreateClipOnActiveTab()
  }

  if (message?.type === 'clip:resolve-status') {
    const id = String(message?.id || '').trim()
    const status = String(message?.status || '').trim()
    if (!id) return Promise.resolve({ ok: false, error: 'id is empty' })
    if (status !== 'ok' && status !== 'broken') return Promise.resolve({ ok: false, error: 'invalid status' })
    return updateClipResolveStatus(id, status).then((item) => ({ ok: !!item, item }))
  }

  if (message?.type === 'clip:reveal') {
    const id = String(message?.id || '').trim()
    if (!id) return Promise.resolve({ ok: false, error: 'id is empty' })
    return getClipById(id).then(async (item) => {
      if (!item) return { ok: false, error: 'clip not found' }
      const tabId = Number.isInteger(item.tabId) && (item.tabId || 0) >= 0 ? (item.tabId as number) : await findExistingTabIdByUrl(item.pageUrl)
      if (!Number.isInteger(tabId) || (tabId as number) < 0) {
        const created = await browser.tabs.create({ url: item.pageUrl, active: true }).catch(() => null)
        if (!Number.isInteger(created?.id)) return { ok: false, error: 'failed to open clip page' }
        return { ok: true, opened: true }
      }
      await browser.tabs.update(tabId as number, { active: true }).catch(() => null)
      const revealResult = (await browser.tabs
        .sendMessage(tabId as number, {
          type: 'clip:reveal',
          id: item.id,
        })
        .catch(() => null)) as { ok?: boolean } | null
      if (!revealResult?.ok) {
        const fallbackOk = await revealClipByScriptingFallback(tabId as number, item)
        if (fallbackOk) {
          await updateClipResolveStatus(item.id, 'ok')
          return { ok: true, fallback: 'scripting' }
        }
        await updateClipResolveStatus(item.id, 'broken')
        return { ok: false, error: 'clip anchor not found in current dom' }
      }
      await updateClipResolveStatus(item.id, 'ok')
      return { ok: true }
    })
  }

  return undefined
})

browser.commands.onCommand.addListener((command) => {
  clipLog('commands.onCommand', command)
  if (command !== 'create_clip_from_selection') return
  void (async () => {
    const tabs = await browser.tabs.query({ active: true, currentWindow: true })
    const tabId = Number(tabs[0]?.id)
    if (Number.isInteger(tabId) && tabId >= 0) {
      const shown = await browser.tabs
        .sendMessage(tabId, { type: 'clip:show-action-bar' })
        .then((v) => Boolean((v as any)?.ok))
        .catch(() => false)
      if (shown) return
      const injectedShown = await showInlineActionBarByScripting(tabId)
      if (injectedShown) return
    }

    const result = await requestCreateClipOnActiveTab()
    if (result.ok) return
    clipLog('commands.onCommand:fail', result)
    await notify(`클립 생성 실패: ${result.error || 'unknown error'}`)
  })()
})

browser.runtime.onInstalled.addListener(() => {
  console.log('[gomdown-helper] onInstalled')
  setupWebRequestInterceptor()
  setupDownloadCancelHook()
  setupContextMenuClickListener()
  void createMenuItem()
  void applyShelfVisibility()
})

browser.runtime.onStartup.addListener(() => {
  console.log('[gomdown-helper] onStartup')
  setupWebRequestInterceptor()
  setupDownloadCancelHook()
  setupContextMenuClickListener()
  void createMenuItem()
  void applyShelfVisibility()
})

browser.storage.onChanged.addListener((changes, areaName) => {
  if (areaName !== 'sync') return
  if (changes.hideChromeBar || changes.useNativeHost || changes.extensionStatus) {
    void applyShelfVisibility()
    void createMenuItem()
  }
  if (changes.showContextOption) {
    void createMenuItem()
  }
})

setupWebRequestInterceptor()
setupDownloadCancelHook()
setupContextMenuClickListener()
void createMenuItem()
void applyShelfVisibility()
console.log('[gomdown-helper] service worker initialized')
