import type { ProviderInfo } from '@filoz/synapse-sdk'
import type { CID } from 'multiformats/cid'
import type { Logger } from 'pino'
import { getErrorMessage } from './errors.js'
import type { ProgressEvent, ProgressEventHandler } from './types.js'

/**
 * Response structure from an IPNI indexer.
 *
 * The indexer returns provider records corresponding with each SP that advertised
 * a given CID to IPNI.
 * Each provider includes their peer ID and multiaddrs.
 */
interface IpniIndexerResponse {
  MultihashResults?: Array<{
    Multihash?: string
    ProviderResults?: ProviderResult[]
  }>
}

/**
 * A single provider's provider record from IPNI.
 *
 * Contains the provider's libp2p peer ID and an array of multiaddrs where
 * the content can be retrieved. These multiaddrs typically include the
 * provider's PDP service endpoint (e.g., /dns/provider.example.com/tcp/443/https).
 *
 * Note: this format matches what IPNI indexers return (see https://cid.contact/cid/bafybeigvgzoolc3drupxhlevdp2ugqcrbcsqfmcek2zxiw5wctk3xjpjwy for an example)
 */
interface ProviderResult {
  Provider?: {
    /** Libp2p peer ID of the storage provider */
    ID?: string
    /** Multiaddrs where this provider can serve the content */
    Addrs?: string[]
  }
}

export type ValidateIPNIProgressEvents =
  | ProgressEvent<'ipniProviderResults.retryUpdate', { retryCount: number }>
  | ProgressEvent<'ipniProviderResults.complete', { result: true; retryCount: number }>
  | ProgressEvent<'ipniProviderResults.failed', { error: Error }>

export interface WaitForIpniProviderResultsOptions {
  /**
   * maximum number of attempts
   *
   * @default: 20
   */
  maxAttempts?: number | undefined

  /**
   * delay between attempts in milliseconds
   *
   * @default: 5000
   */
  delayMs?: number | undefined

  /**
   * Abort signal
   *
   * @default: undefined
   */
  signal?: AbortSignal | undefined

  /**
   * Logger instance
   *
   * @default: undefined
   */
  logger?: Logger | undefined

  /**
   * Providers that are expected to appear in the IPNI provider results. All
   * providers supplied here must be present in the response for the validation
   * to succeed. When omitted or empty, the validation when the IPNI
   * response is non-empty.
   *
   * @default: []
   */
  expectedProviders?: ProviderInfo[] | undefined

  /**
   * Callback for progress updates
   *
   * @default: undefined
   */
  onProgress?: ProgressEventHandler<ValidateIPNIProgressEvents>

  /**
   * IPNI indexer URL to query for provider records to confirm that advertisements were processed.
   *
   * @default 'https://filecoinpin.contact'
   */
  ipniIndexerUrl?: string | undefined
}

/**
 * Check if the IPNI Indexer has the provided ProviderResults for the provided ipfsRootCid.
 * This effectively verifies the entire SP<->IPNI flow, including:
 * - The SP announced the advertisement chain to the IPNI indexer(s)
 * - The IPNI indexer(s) pulled the advertisement chain from the SP
 * - The IPNI indexer(s) updated their index
 * This doesn't check individual steps, but rather the end ProviderResults reponse from the IPNI indexer.
 * If the IPNI indexer ProviderResults have the expected providers, then the steps abomove must have completed.
 * This doesn't actually do any IPFS Mainnet retrieval checks of the ipfsRootCid.
 *
 * This should not be called until you receive confirmation from the SP that the piece has been parked, i.e. `onPieceAdded` in the `synapse.storage.upload` callbacks.
 *
 * @param ipfsRootCid - The IPFS root CID to check
 * @param options - Options for the check
 * @returns True if the IPNI announce succeeded, false otherwise
 */
export async function waitForIpniProviderResults(
  ipfsRootCid: CID,
  options?: WaitForIpniProviderResultsOptions
): Promise<boolean> {
  const delayMs = options?.delayMs ?? 5000
  const maxAttempts = options?.maxAttempts ?? 20
  const ipniIndexerUrl = options?.ipniIndexerUrl ?? 'https://filecoinpin.contact'
  const expectedProviders = options?.expectedProviders?.filter((provider) => provider != null) ?? []
  const { expectedMultiaddrs, skippedProviderCount } = deriveExpectedMultiaddrs(expectedProviders, options?.logger)
  const expectedMultiaddrsSet = new Set(expectedMultiaddrs)

  const hasProviderExpectations = expectedMultiaddrs.size > 0

  // Log a warning if we expected providers but couldn't derive their multiaddrs
  // In this case, we fall back to generic validation (just checking if there are any provider records for the CID)
  if (!hasProviderExpectations && expectedProviders.length > 0 && skippedProviderCount > 0) {
    options?.logger?.info(
      { skippedProviderExpectationCount: skippedProviderCount, expectedProviders: expectedProviders.length },
      'No provider multiaddrs derived from expected providers; falling back to generic IPNI validation'
    )
  }

  return new Promise<boolean>((resolve, reject) => {
    let retryCount = 0
    // Tracks the most recent validation failure reason for error reporting
    let lastFailureReason: string | undefined
    // Tracks the actual multiaddrs found in the last IPNI response for error reporting
    let lastActualMultiaddrs: Set<string> = new Set()

    const check = async (): Promise<void> => {
      if (options?.signal?.aborted) {
        throw new Error('Check IPNI announce aborted', { cause: options?.signal })
      }

      options?.logger?.info(
        {
          event: 'check-ipni-announce',
          ipfsRootCid: ipfsRootCid.toString(),
        },
        'Checking IPNI for announcement of IPFS Root CID "%s"',
        ipfsRootCid.toString()
      )

      // Emit progress event for this attempt
      try {
        options?.onProgress?.({ type: 'ipniProviderResults.retryUpdate', data: { retryCount } })
      } catch (error) {
        options?.logger?.warn({ error }, 'Error in consumer onProgress callback for retryUpdate event')
      }

      // Fetch IPNI provider records
      const fetchOptions: RequestInit = {
        headers: { Accept: 'application/json' },
      }
      if (options?.signal) {
        fetchOptions.signal = options?.signal
      }

      let response: Response | undefined
      try {
        response = await fetch(`${ipniIndexerUrl}/cid/${ipfsRootCid}`, fetchOptions)
      } catch (fetchError) {
        lastActualMultiaddrs = new Set()
        lastFailureReason = `Failed to query IPNI indexer: ${getErrorMessage(fetchError)}`
        options?.logger?.warn({ error: fetchError }, `${lastFailureReason}. Retrying...`)
      }

      // Parse and validate response
      if (response?.ok) {
        let providerResults: ProviderResult[] = []
        try {
          const body = (await response.json()) as IpniIndexerResponse
          // Extract provider results
          providerResults = (body.MultihashResults ?? []).flatMap((r) => r.ProviderResults ?? [])
          // Extract all multiaddrs from provider results
          lastActualMultiaddrs = new Set(providerResults.flatMap((pr) => pr.Provider?.Addrs ?? []))
          lastFailureReason = undefined
        } catch (parseError) {
          // Clear actual multiaddrs on parse error
          lastActualMultiaddrs = new Set()
          lastFailureReason = `Failed to parse IPNI response body: ${getErrorMessage(parseError)}`
          options?.logger?.warn({ error: parseError }, `${lastFailureReason}. Retrying...`)
        }

        // Check if we have provider results to validate
        if (providerResults.length > 0) {
          let isValid = false

          if (hasProviderExpectations) {
            // Find matching multiaddrs

            const matchedMultiaddrs = lastActualMultiaddrs.intersection(expectedMultiaddrsSet)
            isValid = matchedMultiaddrs.size === expectedMultiaddrs.size

            if (!isValid) {
              // Log validation gap
              const missingMultiaddrs = expectedMultiaddrsSet.difference(matchedMultiaddrs)
              lastFailureReason = `Missing provider records with expected multiaddr(s): ${Array.from(missingMultiaddrs).join(', ')}`
              options?.logger?.info(
                {
                  receivedMultiaddrs: lastActualMultiaddrs,
                  matchedMultiaddrs,
                  missingMultiaddrs,
                },
                `${lastFailureReason}. Retrying...`
              )
            }
          } else {
            // Generic validation: just need any provider with addresses
            isValid = lastActualMultiaddrs.size > 0
            if (!isValid) {
              lastFailureReason = 'Expected at least one provider record'
              options?.logger?.info(`${lastFailureReason}. Retrying...`)
            }
          }

          if (isValid) {
            // Validation succeeded!
            try {
              options?.onProgress?.({ type: 'ipniProviderResults.complete', data: { result: true, retryCount } })
            } catch (error) {
              options?.logger?.warn({ error }, 'Error in consumer onProgress callback for complete event')
            }
            resolve(true)
            return
          }
        } else if (lastFailureReason == null) {
          // Only set generic message if we don't already have a more specific reason (e.g., parse error)
          lastFailureReason = 'IPNI response did not include any provider results'
          // Track that we got an empty response
          lastActualMultiaddrs = new Set()
          options?.logger?.info(
            { providerResultsCount: providerResults?.length ?? 0 },
            `${lastFailureReason}. Retrying...`
          )
        }
      } else if (response != null) {
        lastActualMultiaddrs = new Set()
        lastFailureReason = `IPNI indexer request failed with status ${response.status}`
        options?.logger?.info(
          { status: response.status, statusText: response.statusText },
          `${lastFailureReason}. Retrying...`
        )
      }

      // Retry or fail
      if (++retryCount < maxAttempts) {
        options?.logger?.info(
          { retryCount, maxAttempts },
          'IPFS Root CID "%s" not announced to IPNI yet (%d/%d). Retrying in %dms...',
          ipfsRootCid.toString(),
          retryCount,
          maxAttempts,
          delayMs
        )
        await new Promise((resolve) => setTimeout(resolve, delayMs))
        await check()
      } else {
        // Max attempts reached - validation failed
        const msgBase = `IPFS root CID "${ipfsRootCid.toString()}" does not have expected IPNI ProviderResults after ${maxAttempts} attempt${maxAttempts === 1 ? '' : 's'}`
        let msg = msgBase
        if (lastFailureReason != null) {
          msg = `${msgBase}. Last observation: ${lastFailureReason}`
        }
        // Include expected and actual multiaddrs for debugging
        if (hasProviderExpectations) {
          msg = `${msg}. Expected multiaddrs: [${Array.from(expectedMultiaddrs).join(', ')}]. Actual multiaddrs in response: [${Array.from(lastActualMultiaddrs).join(', ')}]`
        }
        const error = new Error(msg)
        options?.logger?.warn({ error }, msg)
        throw error
      }
    }

    check().catch((error) => {
      try {
        options?.onProgress?.({ type: 'ipniProviderResults.failed', data: { error } })
      } catch (callbackError) {
        options?.logger?.warn({ error: callbackError }, 'Error in consumer onProgress callback for failed event')
      }
      reject(error)
    })
  })
}

/**
 * Convert a PDP service URL to an IPNI multiaddr format.
 *
 * Storage providers expose their PDP (Proof of Data Possession) service via HTTP/HTTPS
 * endpoints (e.g., "https://provider.example.com:8443"). When they advertise content
 * to IPNI, they include multiaddrs in libp2p format (e.g., "/dns/provider.example.com/tcp/8443/https").
 *
 * This function converts between these representations to enable validation that a
 * provider's IPNI provider records matches their registered service endpoint.
 *
 * @param serviceURL - HTTP/HTTPS URL of the provider's PDP service
 * @param logger - Optional logger for warnings
 * @returns Multiaddr string in libp2p format, or undefined if conversion fails
 *
 * @example
 * serviceURLToMultiaddr('https://provider.example.com')
 * // Returns: '/dns/provider.example.com/tcp/443/https'
 *
 * @example
 * serviceURLToMultiaddr('http://provider.example.com:8080')
 * // Returns: '/dns/provider.example.com/tcp/8080/http'
 */
export function serviceURLToMultiaddr(serviceURL: string, logger?: Logger): string | undefined {
  try {
    const url = new URL(serviceURL)
    const port = url.port || (url.protocol === 'https:' ? '443' : '80')
    const protocolComponent = url.protocol.replace(':', '')

    return `/dns/${url.hostname}/tcp/${port}/${protocolComponent}`
  } catch (error) {
    const reason = getErrorMessage(error)
    logger?.warn({ serviceURL, error }, `Unable to derive IPNI multiaddr from serviceURL: ${reason}`)
    return undefined
  }
}

/**
 * Derive expected IPNI multiaddrs from provider information.
 *
 * For each provider, attempts to extract their PDP serviceURL and convert it to
 * the multiaddr format used in IPNI advertisements. This allows validation that
 * specific providers have advertised the content.
 *
 * Note: ProviderInfo should contain the serviceURL at `products.PDP.data.serviceURL`.
 *
 * @param providers - Array of provider info objects from synapse SDK
 * @param logger - Optional logger for diagnostics
 * @returns Expected multiaddrs and count of providers that couldn't be processed
 */
function deriveExpectedMultiaddrs(
  providers: ProviderInfo[],
  logger: Logger | undefined
): {
  expectedMultiaddrs: Set<string>
  skippedProviderCount: number
} {
  const derivedMultiaddrs: Set<string> = new Set()
  let skippedProviderCount = 0

  for (const provider of providers) {
    const serviceURL = provider.products?.PDP?.data?.serviceURL

    if (!serviceURL) {
      skippedProviderCount++
      logger?.warn({ provider }, 'Expected provider is missing a PDP serviceURL; skipping IPNI multiaddr expectation')
      continue
    }

    const derivedMultiaddr = serviceURLToMultiaddr(serviceURL, logger)
    if (!derivedMultiaddr) {
      skippedProviderCount++
      logger?.warn({ provider, serviceURL }, 'Unable to derive IPNI multiaddr from serviceURL; skipping expectation')
      continue
    }

    derivedMultiaddrs.add(derivedMultiaddr)
  }

  return {
    expectedMultiaddrs: derivedMultiaddrs,
    skippedProviderCount,
  }
}
