// Copyright 2025-present 650 Industries. All rights reserved.

import Foundation

/// A `Sendable` snapshot of a single HTTP request observed by `NetworkRequestTaskSwizzling`.
///
/// The shape is intentionally close to `URLSessionTaskTransactionMetrics` so we can plumb individual
/// timing phases into telemetry later without redesigning the type. Until the metrics pipeline is
/// wired up, this is what gets handed to `NetworkRequestObserverDelegate` callers.
///
/// The URL is recorded verbatim — including the query string — because callers asked for it. Any
/// redaction (tokens in query parameters, auth headers, etc.) is the responsibility of whatever
/// downstream layer converts these snapshots into stored metrics.
public struct NetworkRequest: Sendable, Equatable, Identifiable {
  /// Stable identifier for this observation. Generated by the protocol class.
  public let id: UUID

  /// Request URL as supplied to `URLSession`. May include query parameters and fragments.
  public let url: URL

  /// HTTP method (`GET`, `POST`, …). Defaults to `GET` if `URLRequest.httpMethod` was nil.
  public let method: String

  /// Response status code, or `nil` if the request failed before headers were received.
  public let statusCode: Int?

  /// Negotiated wire protocol — `http/1.1`, `h2`, `h3` — as reported by
  /// `URLSessionTaskTransactionMetrics.networkProtocolName`. `nil` for cache hits or when the OS
  /// didn't report one.
  public let networkProtocol: String?

  /// Number of bytes sent on the wire for the request (headers + body).
  public let requestBytesSent: Int64?

  /// Number of bytes received on the wire for the response (headers + body).
  public let responseBytesReceived: Int64?

  /// Phase-by-phase timings pulled from the most recent (post-redirect) transaction.
  public let timings: Timings

  /// Short human-readable error description if the task completed with an error. We keep this as a
  /// string rather than carrying `NSError` so the type stays `Sendable` and serializable.
  public let errorDescription: String?

  /// Ordered list of redirect hops that preceded the final response. Empty when the task returned
  /// directly. Each entry describes one hop: `fromUrl` is the URL that returned the redirect,
  /// `statusCode` is the 3xx code it returned, and `toUrl` is where the redirect pointed. For a
  /// complete chain the first entry's `fromUrl` equals the parent event's `url`, and the last
  /// entry's `toUrl` is where the request actually landed.
  public let redirects: [Redirect]

  public struct Redirect: Sendable, Equatable {
    /// The URL that returned the redirect.
    public let fromUrl: URL
    /// The URL the request was redirected to.
    public let toUrl: URL
    /// The 3xx status code returned by `fromUrl` that caused this hop.
    public let statusCode: Int
  }

  public struct Timings: Sendable, Equatable {
    /// When the task was started (`URLSessionTaskTransactionMetrics.fetchStartDate`).
    public let fetchStart: Date?

    /// When DNS resolution began. `nil` if the host was resolved from cache or the connection was reused.
    public let domainLookupStart: Date?
    public let domainLookupEnd: Date?

    /// When the TCP connection began. `nil` if a connection was reused.
    public let connectStart: Date?
    public let connectEnd: Date?

    /// TLS handshake window. `nil` for cleartext or reused connections.
    public let secureConnectionStart: Date?
    public let secureConnectionEnd: Date?

    /// When the request line began being sent.
    public let requestStart: Date?
    public let requestEnd: Date?

    /// When the first byte of the response arrived (TTFB).
    public let responseStart: Date?
    public let responseEnd: Date?

    /// Total wall-clock duration of the task. Convenience: callers don't have to subtract
    /// `fetchStart` from `responseEnd` themselves, and we can populate this even when the
    /// individual phases are `nil` (cache hits, errors before headers).
    public let totalDuration: TimeInterval
  }
}

/// Lightweight snapshot emitted when a request begins, before any response or timing data exists.
/// Shares its `id` with the corresponding completion-time `NetworkRequest`, so JS subscribers can
/// correlate the two events.
public struct NetworkRequestStarted: Sendable, Equatable, Identifiable {
  public let id: UUID
  public let url: URL
  public let method: String
  public let startedAt: Date
}

extension NetworkRequest {
  /// Builds a snapshot from the data we have at task completion. The metrics argument may be `nil`
  /// for cache-only responses or in tests; in that case, callers fall back to wall-clock timestamps
  /// on the request/response pair.
  ///
  /// `taskBytesSent` / `taskBytesReceived` are the task's wall-clock byte counters
  /// (`URLSessionTask.countOfBytesSent` / `countOfBytesReceived`). Pass `nil` when no task is
  /// available (tests). They're used as a fallback when `metrics`'s per-transaction counters are
  /// zero — see the body for the cache-hit / Simulator quirks that hit that case.
  static func from(
    id: UUID,
    request: URLRequest,
    response: HTTPURLResponse?,
    taskBytesSent: Int64?,
    taskBytesReceived: Int64?,
    metrics: URLSessionTaskMetrics?,
    fallbackStart: Date,
    fallbackEnd: Date,
    error: Error?
  ) -> NetworkRequest {
    let url = request.url ?? URL(string: "about:blank")!
    let method = request.httpMethod ?? "GET"

    // We use the last transaction so that redirects don't drop us back to the first hop. If a
    // future need arises to surface per-hop timing, expose `metrics.transactionMetrics` here.
    let transaction = metrics?.transactionMetrics.last

    let timings = NetworkRequest.Timings(
      fetchStart: transaction?.fetchStartDate ?? fallbackStart,
      domainLookupStart: transaction?.domainLookupStartDate,
      domainLookupEnd: transaction?.domainLookupEndDate,
      connectStart: transaction?.connectStartDate,
      connectEnd: transaction?.connectEndDate,
      secureConnectionStart: transaction?.secureConnectionStartDate,
      secureConnectionEnd: transaction?.secureConnectionEndDate,
      requestStart: transaction?.requestStartDate,
      requestEnd: transaction?.requestEndDate,
      responseStart: transaction?.responseStartDate,
      responseEnd: transaction?.responseEndDate ?? fallbackEnd,
      totalDuration: metrics?.taskInterval.duration ?? fallbackEnd.timeIntervalSince(fallbackStart)
    )

    // Prefer the transaction's header+body split when it reports non-zero, fall back to the task's
    // wall-clock counters otherwise. The transaction counters are 0 in two real cases:
    // (1) cache hits — `resourceFetchType == .localCache`, no wire traffic occurred;
    // (2) iOS Simulator — Apple's socket-level instrumentation isn't always wired up there, so
    //     the counters return 0 even when the request actually moved bytes. `task.countOfBytesSent/
    //     Received` is wall-clock accurate in both environments.
    let requestBytesSent: Int64? = {
      if let transaction {
        let fromTransaction = transaction.countOfRequestHeaderBytesSent + transaction.countOfRequestBodyBytesSent
        if fromTransaction > 0 {
          return fromTransaction
        }
      }
      return taskBytesSent
    }()
    let responseBytesReceived: Int64? = {
      if let transaction {
        let fromTransaction =
          transaction.countOfResponseHeaderBytesReceived + transaction.countOfResponseBodyBytesReceived
        if fromTransaction > 0 {
          return fromTransaction
        }
      }
      return taskBytesReceived
    }()

    // Each redirect entry pairs the 3xx status from one transaction with the URLs on either side
    // of the hop: `fromUrl` is the URL we requested that returned the redirect, `toUrl` is the
    // URL the redirect pointed to (which is the next transaction's request URL).
    //
    // `transactionMetrics` can contain non-redirect transactions too (HTTP/2 → HTTP/3 Alt-Svc
    // upgrades, connection retries, HTTP → HTTPS upgrades), so we filter explicitly on 3xx
    // status codes rather than assuming "everything before the last is a redirect."
    let redirects: [Redirect] = {
      guard let transactions = metrics?.transactionMetrics, transactions.count > 1 else {
        return []
      }
      var result: [Redirect] = []
      for index in 0..<(transactions.count - 1) {
        let current = transactions[index]
        let next = transactions[index + 1]
        guard
          let response = current.response as? HTTPURLResponse,
          (300..<400).contains(response.statusCode),
          let fromUrl = current.request.url,
          let toUrl = next.request.url
        else {
          continue
        }
        result.append(Redirect(fromUrl: fromUrl, toUrl: toUrl, statusCode: response.statusCode))
      }
      return result
    }()

    return NetworkRequest(
      id: id,
      url: url,
      method: method,
      statusCode: response?.statusCode,
      networkProtocol: transaction?.networkProtocolName,
      requestBytesSent: requestBytesSent,
      responseBytesReceived: responseBytesReceived,
      timings: timings,
      errorDescription: error.map { ($0 as NSError).localizedDescription },
      redirects: redirects
    )
  }
}
