/**
 * Transport layer for the ParkSmarter API.
 *
 * This mirrors the request pipeline used by the official app:
 *   - Base URL + query string are concatenated onto the endpoint path.
 *   - Query params are serialized as `?k=encodeURIComponent(v)&...`.
 *   - Headers:
 *       Application_Token      always (identifies the app build)
 *       X-Request-Id           always (a fresh UUID per request)
 *       Content-Type: application/json   on POST/PUT
 *       Auth_Token             when the endpoint requires an authenticated user
 *       ParkSmarter_SessionId  when a server session id is available
 *   - The user auth token and session id are NOT HTTP bearer tokens; they are
 *     custom headers named exactly `Auth_Token` and `ParkSmarter_SessionId`.
 */
import { Environment } from './environments.js';

export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

export interface TokenStore {
  /** Current user auth token, or null if signed out. */
  getAuthToken(): string | null | Promise<string | null>;
  setAuthToken(token: string | null): void | Promise<void>;
  /** Current server session id, or null. */
  getSessionId(): string | null | Promise<string | null>;
  setSessionId(sessionId: string | null): void | Promise<void>;
}

/** Simple in-memory token store. Swap for expo-secure-store / localStorage in real apps. */
export class MemoryTokenStore implements TokenStore {
  private authToken: string | null = null;
  private sessionId: string | null = null;
  getAuthToken() {
    return this.authToken;
  }
  setAuthToken(token: string | null) {
    this.authToken = token;
  }
  getSessionId() {
    return this.sessionId;
  }
  setSessionId(sessionId: string | null) {
    this.sessionId = sessionId;
  }
}

export interface RequestOptions {
  method: HttpMethod;
  /** Endpoint path, e.g. `/api/Auth`. */
  path: string;
  query?: Record<string, unknown> | undefined;
  body?: unknown;
  /** Send the `Auth_Token` header (default: true except for auth/public endpoints). */
  includeAuthToken?: boolean;
  /** Send the `ParkSmarter_SessionId` header when available (default: true). */
  includeSessionId?: boolean;
  /** Abort signal (also drives the per-request timeout). */
  signal?: AbortSignal;
}

export interface ParkSmarterResponse<T> {
  status: number;
  data: T;
  headers: Headers;
  requestId: string;
}

/** Thrown for non-2xx responses. `body` is the parsed server payload when JSON. */
export class ParkSmarterApiError extends Error {
  status: number;
  body: unknown;
  requestId: string;
  /** Server-provided message when present (server uses PascalCase `Message`). */
  serverMessage?: string;
  constructor(status: number, body: unknown, requestId: string) {
    const serverMessage =
      body && typeof body === 'object'
        ? (body as Record<string, unknown>).Message ??
          (body as Record<string, unknown>).message
        : undefined;
    super(
      `ParkSmarter API error ${status}` +
        (serverMessage ? `: ${serverMessage}` : ''),
    );
    this.name = 'ParkSmarterApiError';
    this.status = status;
    this.body = body;
    this.requestId = requestId;
    if (typeof serverMessage === 'string') this.serverMessage = serverMessage;
  }
}

export interface HttpClientConfig {
  environment: Environment;
  tokens: TokenStore;
  /** BCP-47-ish locale code sent as the `localeCode` query param (default: 'en'). */
  localeCode?: string;
  /** Per-request timeout in ms (default: 30000). */
  timeoutMs?: number;
  /** Override fetch (e.g. for React Native or tests). Defaults to global fetch. */
  fetchImpl?: typeof fetch;
  /** Override UUID generation. Defaults to crypto.randomUUID when available. */
  uuid?: () => string;
  /**
   * Called whenever an authenticated request comes back 401. Use it to clear the
   * stored token and route the user back to sign-in. Fired before the error throws.
   */
  onUnauthorized?: () => void;
  /**
   * When true, logs every request/response (method, URL, headers, body, status)
   * via `logSink` (default: console.log). Sensitive header/body values are
   * redacted. Handy for capturing traffic in logcat while debugging; turn off in
   * production.
   */
  logRequests?: boolean;
  /** Where log lines go when logRequests is on. Defaults to console.log. */
  logSink?: (line: string) => void;
}

/** Header/body keys whose VALUES must never be logged in the clear. */
const SENSITIVE_KEYS = new Set(
  [
    'Auth_Token',
    'Application_Token',
    'ParkSmarter_SessionId',
    'Password',
    'OldPassword',
    'NewPassword',
    'CCNumber',
    'CCExpDate',
    'CCZip',
    'CCFirstSix',
    'CCLastFour',
    'ProviderAuth',
    'ProviderIdentity',
  ].map((k) => k.toLowerCase()),
);

function redact(obj: unknown): unknown {
  if (!obj || typeof obj !== 'object') return obj;
  const out: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
    if (SENSITIVE_KEYS.has(k.toLowerCase())) {
      out[k] = typeof v === 'string' ? `<redacted:${v.length}>` : '<redacted>';
    } else if (v && typeof v === 'object') {
      out[k] = redact(v);
    } else {
      out[k] = v;
    }
  }
  return out;
}

function defaultUuid(): string {
  const c = (globalThis as { crypto?: Crypto }).crypto;
  if (c && typeof c.randomUUID === 'function') return c.randomUUID();
  // RFC4122-ish fallback (non-crypto) for older runtimes.
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
    const r = (Math.random() * 16) | 0;
    const v = ch === 'x' ? r : (r & 0x3) | 0x8;
    return v.toString(16);
  });
}

function serializeQuery(query: Record<string, unknown>): string {
  const parts: string[] = [];
  for (const [key, value] of Object.entries(query)) {
    if (value === undefined || value === null) continue;
    parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
  }
  return parts.length ? `?${parts.join('&')}` : '';
}

export class HttpClient {
  private cfg: Required<Omit<HttpClientConfig, 'environment' | 'tokens'>> &
    Pick<HttpClientConfig, 'environment' | 'tokens'>;

  constructor(config: HttpClientConfig) {
    this.cfg = {
      environment: config.environment,
      tokens: config.tokens,
      localeCode: config.localeCode ?? 'en',
      timeoutMs: config.timeoutMs ?? 30000,
      fetchImpl: config.fetchImpl ?? globalThis.fetch?.bind(globalThis),
      uuid: config.uuid ?? defaultUuid,
      onUnauthorized: config.onUnauthorized ?? (() => {}),
      logRequests: config.logRequests ?? false,
      logSink: config.logSink ?? ((line: string) => console.log(line)),
    };
    if (!this.cfg.fetchImpl) {
      throw new Error(
        'No fetch implementation available. Pass config.fetchImpl (e.g. node-fetch, or a polyfill).',
      );
    }
  }

  get environment(): Environment {
    return this.cfg.environment;
  }

  get tokens(): TokenStore {
    return this.cfg.tokens;
  }

  setLocaleCode(localeCode: string) {
    this.cfg.localeCode = localeCode;
  }

  /** Toggle request/response logging at runtime (e.g. from a Settings switch). */
  setLogRequests(on: boolean) {
    this.cfg.logRequests = on;
  }

  async request<T>(opts: RequestOptions): Promise<ParkSmarterResponse<T>> {
    const { method } = opts;
    const requestId = this.cfg.uuid();

    // The app appends `localeCode` to the query params of essentially every call.
    const query: Record<string, unknown> = {
      localeCode: this.cfg.localeCode,
      ...(opts.query ?? {}),
    };
    const url =
      this.cfg.environment.baseUrl + opts.path + serializeQuery(query);

    const headers: Record<string, string> = {
      Application_Token: this.cfg.environment.appToken,
      'X-Request-Id': requestId,
      Accept: 'application/json',
    };
    if (method === 'POST' || method === 'PUT') {
      headers['Content-Type'] = 'application/json';
    }

    const includeAuthToken = opts.includeAuthToken ?? true;
    const includeSessionId = opts.includeSessionId ?? true;

    if (includeAuthToken) {
      const authToken = await this.cfg.tokens.getAuthToken();
      if (authToken) headers['Auth_Token'] = authToken;
    }
    if (includeSessionId) {
      const sessionId = await this.cfg.tokens.getSessionId();
      if (sessionId) headers['ParkSmarter_SessionId'] = sessionId;
    }

    // Timeout wired through an AbortController, honoring any caller-supplied signal.
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs);
    if (opts.signal) {
      if (opts.signal.aborted) controller.abort();
      else opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
    }

    if (this.cfg.logRequests) {
      this.cfg.logSink(
        `[PS →] ${method} ${url}\n  headers: ${JSON.stringify(redact(headers))}` +
          (opts.body !== undefined && opts.body !== null
            ? `\n  body: ${JSON.stringify(redact(opts.body))}`
            : ''),
      );
    }

    let res: Response;
    try {
      res = await this.cfg.fetchImpl(url, {
        method,
        headers,
        body:
          opts.body !== undefined && opts.body !== null
            ? JSON.stringify(opts.body)
            : undefined,
        signal: controller.signal,
      });
    } finally {
      clearTimeout(timer);
    }

    const text = await res.text();
    let data: unknown = undefined;
    if (text && text.trim().length) {
      try {
        data = JSON.parse(text);
      } catch {
        data = text;
      }
    }

    // Keep the token store fresh from what the server echoes back:
    //   - SessionId is returned on bootstrap/login.
    //   - A fresh Auth_Token may be returned either at the top level (login) or
    //     inside the common `Response: { Auth_Token, Message, Status }` envelope
    //     (a rolling token-refresh mechanism). Only overwrite on a non-empty value.
    if (data && typeof data === 'object') {
      const obj = data as Record<string, unknown>;
      const envelope = obj.Response as Record<string, unknown> | undefined;

      const sessionId = obj.SessionId;
      if (typeof sessionId === 'string' && sessionId) {
        await this.cfg.tokens.setSessionId(sessionId);
      }

      const refreshed =
        (typeof obj.Auth_Token === 'string' && obj.Auth_Token) ||
        (envelope && typeof envelope.Auth_Token === 'string' && envelope.Auth_Token);
      if (refreshed) {
        await this.cfg.tokens.setAuthToken(refreshed);
      }
    }

    if (this.cfg.logRequests) {
      const preview =
        data && typeof data === 'object'
          ? JSON.stringify(redact(data)).slice(0, 500)
          : String(text).slice(0, 300);
      this.cfg.logSink(`[PS ←] ${res.status} ${method} ${opts.path}  ${preview}`);
    }

    if (!res.ok) {
      if (res.status === 401) {
        // Token invalid/rotated/expired — let the app clear it and re-auth.
        await this.cfg.tokens.setAuthToken(null);
        this.cfg.onUnauthorized();
      }
      throw new ParkSmarterApiError(res.status, data ?? text, requestId);
    }

    return { status: res.status, data: data as T, headers: res.headers, requestId };
  }
}
