/**
 * ParkSmarterClient — a typed wrapper over the ParkSmarter (IPS Group) REST API.
 *
 * Reverse-engineered from the official Android app (com.ipsgroupinc.parksmarter 4.4.0,
 * a React Native / Hermes build). All endpoint paths, header names, the app-token
 * scheme, and request/response field names were recovered from the app bundle.
 *
 * Auth model (NOT OAuth bearer):
 *   - `Application_Token`      : identifies the app build; required on every call.
 *   - `Auth_Token`            : the logged-in user's token; returned by POST /api/Auth
 *                               (login) in the body field `Auth_Token`, then sent as a
 *                               header on authenticated calls.
 *   - `ParkSmarter_SessionId` : a server session id; seeded by
 *                               GET /api/ApplicationValidity and login, echoed on some
 *                               responses. Sent as a header when present.
 *   - `X-Request-Id`          : a fresh UUID per request.
 *
 * The client persists Auth_Token and SessionId into the provided TokenStore
 * automatically after login / bootstrap.
 */
import {
  DEFAULT_ENVIRONMENT,
  ENVIRONMENTS,
  Environment,
  EnvironmentName,
} from './environments.js';
import {
  HttpClient,
  MemoryTokenStore,
  ParkSmarterResponse,
  TokenStore,
} from './http.js';
import * as T from './types.js';

export interface ParkSmarterClientOptions {
  /** Named environment (default 'prodv2') or a fully custom Environment. */
  environment?: EnvironmentName | Environment;
  /** Token persistence. Defaults to in-memory. Use secure storage in real apps. */
  tokens?: TokenStore;
  /** Locale sent as the `localeCode` query param (default 'en'). */
  localeCode?: string;
  timeoutMs?: number;
  /** Provide a fetch impl for React Native / Node < 18 / tests. */
  fetchImpl?: typeof fetch;
  uuid?: () => string;
  /** Called when any authenticated request returns 401 (token cleared automatically). */
  onUnauthorized?: () => void;
  /** Log every request/response (redacted) — useful for on-device debugging via logcat. */
  logRequests?: boolean;
  /** Where log lines go (default console.log). */
  logSink?: (line: string) => void;
}

/** Thrown when credentials are rejected (the server signals this via a 200 + Status:"Error"). */
export class LoginError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'LoginError';
  }
}

function resolveEnvironment(
  env: EnvironmentName | Environment | undefined,
): Environment {
  if (!env) return ENVIRONMENTS[DEFAULT_ENVIRONMENT];
  if (typeof env === 'string') return ENVIRONMENTS[env];
  return env;
}

export class ParkSmarterClient {
  readonly http: HttpClient;
  readonly tokens: TokenStore;

  constructor(options: ParkSmarterClientOptions = {}) {
    this.tokens = options.tokens ?? new MemoryTokenStore();
    this.http = new HttpClient({
      environment: resolveEnvironment(options.environment),
      tokens: this.tokens,
      localeCode: options.localeCode,
      timeoutMs: options.timeoutMs,
      fetchImpl: options.fetchImpl,
      uuid: options.uuid,
      onUnauthorized: options.onUnauthorized,
      logRequests: options.logRequests,
      logSink: options.logSink,
    });
  }

  /** Set the locale used for the `localeCode` query param on subsequent calls. */
  setLocale(localeCode: string): void {
    this.http.setLocaleCode(localeCode);
  }

  /** Toggle redacted request/response logging at runtime. */
  setLogRequests(on: boolean): void {
    this.http.setLogRequests(on);
  }

  private unwrap<D>(p: Promise<ParkSmarterResponse<D>>): Promise<D> {
    return p.then((r) => r.data);
  }

  /* ============================================================== */
  /* Bootstrap / app validity                                       */
  /* ============================================================== */

  /**
   * GET /api/ApplicationValidity — call this first. Returns feature flags,
   * maintenance/upgrade status, and a SessionId that is stored automatically.
   * Public (no Auth_Token required).
   */
  async getApplicationValidity(): Promise<T.ApplicationValidityResponse> {
    const res = await this.http.request<T.ApplicationValidityResponse>({
      method: 'GET',
      path: '/api/ApplicationValidity',
      query: { ApplicationToken: this.http.environment.appToken },
      includeAuthToken: false,
    });
    return res.data;
  }

  /* ============================================================== */
  /* Auth                                                           */
  /* ============================================================== */

  /**
   * The server returns HTTP 200 even for a FAILED login, signalling the failure
   * only via `Status: "Error"` + a null `Auth_Token`. Detect that here so callers
   * get a real error instead of a phantom "signed-in" state.
   */
  private finishLogin(data: T.AuthResponse): Promise<T.AuthResponse> {
    const token = typeof data?.Auth_Token === 'string' ? data.Auth_Token : '';
    if (!token || data?.Status === 'Error') {
      throw new LoginError(data?.Message || 'Invalid login or password.');
    }
    return this.tokens.setAuthToken
      ? Promise.resolve(this.tokens.setAuthToken(token)).then(() => data)
      : Promise.resolve(data);
  }

  /** POST /api/Auth — phone + password login. Persists Auth_Token & SessionId. */
  async loginWithPhone(params: T.LoginWithPhoneParams): Promise<T.AuthResponse> {
    const res = await this.http.request<T.AuthResponse>({
      method: 'POST',
      path: '/api/Auth',
      body: { UserName: params.phoneNumber, Password: params.password },
      includeAuthToken: false,
    });
    return this.finishLogin(res.data);
  }

  /** POST /api/Auth — Sign in with Apple. Persists Auth_Token & SessionId. */
  async loginWithApple(params: T.LoginWithAppleParams): Promise<T.AuthResponse> {
    const res = await this.http.request<T.AuthResponse>({
      method: 'POST',
      path: '/api/Auth',
      body: {
        EmailAddress: params.emailAddress,
        AppleUserId: params.appleId,
        ProviderAuth: params.providerAuth,
        ProviderIdentity: params.providerIdentity,
      },
      includeAuthToken: false,
    });
    return this.finishLogin(res.data);
  }

  /** Re-authenticate using a cached Auth_Token (sets it, then callers can bootstrap). */
  async loginWithCachedToken(authToken: string): Promise<void> {
    await this.tokens.setAuthToken(authToken);
  }

  /** POST /api/Auth/Logout — invalidate the token on all devices. */
  async logoutAllDevices(): Promise<void> {
    await this.http.request({ method: 'POST', path: '/api/Auth/Logout' });
  }

  /** Clear local tokens (client-side sign out). */
  async logoutLocal(): Promise<void> {
    await this.tokens.setAuthToken(null);
    await this.tokens.setSessionId(null);
  }

  /* ============================================================== */
  /* Sign up, password, verification                                */
  /* ============================================================== */

  /** POST /api/User — create an account. Public. */
  async signUp(params: T.SignUpParams): Promise<boolean> {
    await this.http.request({
      method: 'POST',
      path: '/api/User',
      body: {
        PersonalEmailAddress: params.emailAddress,
        PersonalPhone: params.mobilePhone,
        Password: params.password,
      },
      includeAuthToken: false,
    });
    return true;
  }

  /** POST /api/Password — request a password reset. Public. */
  async requestResetPassword(
    params: T.RequestResetPasswordParams,
  ): Promise<void> {
    await this.http.request({
      method: 'POST',
      path: '/api/Password',
      body: {
        ResetPasswordType: params.reqType,
        PersonalEmailAddress: '',
        PersonalPhone: params.phoneNumber,
      },
      includeAuthToken: false,
    });
  }

  /** PUT /api/Password — change password (authenticated). */
  async updatePassword(params: T.UpdatePasswordParams): Promise<void> {
    await this.http.request({
      method: 'PUT',
      path: '/api/Password',
      body: { OldPassword: params.oldPassword, NewPassword: params.newPassword },
    });
  }

  /** POST /api/UserVerification — request an SMS verification code. */
  async requestVerifyUser(params: T.RequestVerifyUserParams): Promise<void> {
    await this.http.request({
      method: 'POST',
      path: '/api/UserVerification',
      body: { PhoneNumber: '1' + params.phoneNumber },
    });
  }

  /** GET /api/UserVerification — confirm an SMS code. */
  async verifyUser(params: T.VerifyUserParams): Promise<void> {
    await this.http.request({
      method: 'GET',
      path: '/api/UserVerification',
      query: {
        PhoneNumber: '1' + params.phoneNumber,
        ConfirmationCode: params.code,
      },
    });
  }

  /** GET /api/User?EmailAddress=… — is this email already registered? */
  async isEmailRegistered(emailAddress: string): Promise<boolean> {
    const res = await this.http.request<T.UserDetail>({
      method: 'GET',
      path: '/api/User',
      query: { EmailAddress: emailAddress },
      includeAuthToken: false,
    });
    return Boolean(res.data?.PersonalEmailAddress);
  }

  /** GET /api/User?PhoneNumber=… — is this phone already registered? */
  async isPhoneRegistered(phoneNumber: string): Promise<boolean> {
    const res = await this.http.request<T.UserDetail>({
      method: 'GET',
      path: '/api/User',
      query: { PhoneNumber: phoneNumber },
      includeAuthToken: false,
    });
    return Boolean(res.data?.PersonalPhone);
  }

  /* ============================================================== */
  /* User profile                                                   */
  /* ============================================================== */

  /** GET /api/User — full profile incl. vehicles & cards (authenticated). */
  getUserDetail(): Promise<T.UserDetail> {
    return this.unwrap(
      this.http.request<T.UserDetail>({
        method: 'GET',
        path: '/api/User',
        query: { EmailAddress: '', PhoneNumber: '' },
      }),
    );
  }

  /** PUT /api/User — update email/phone (authenticated). */
  async updateProfile(params: T.UpdateProfileParams): Promise<void> {
    await this.http.request({
      method: 'PUT',
      path: '/api/User',
      body: {
        PersonalEmailAddress: params.emailAddress,
        PersonalPhone: params.phoneNumber,
      },
    });
  }

  /** DELETE /api/User — request account deletion (authenticated). */
  async requestDeleteUser(): Promise<void> {
    await this.http.request({
      method: 'DELETE',
      path: '/api/User',
      query: { DeviceType: 'Android' },
    });
  }

  /** PUT /api/Device — register/update the push device token. */
  async updateDeviceToken(
    params: T.UpdateDeviceTokenParams,
  ): Promise<T.UpdateDeviceTokenResponse> {
    return this.unwrap(
      this.http.request<T.UpdateDeviceTokenResponse>({
        method: 'PUT',
        path: '/api/Device',
        body: {
          DeviceID: params.pushNotificationsToken,
          IMEINumber: params.imeiNumber,
          DeviceType: params.deviceType ?? '1',
          Language: params.language,
        },
      }),
    );
  }

  /* ============================================================== */
  /* Vehicles                                                       */
  /* ============================================================== */

  /** POST /api/Vehicle — add a vehicle. */
  async addVehicle(params: T.AddVehicleParams): Promise<T.VehicleMutationResponse> {
    return this.unwrap(
      this.http.request<T.VehicleMutationResponse>({
        method: 'POST',
        path: '/api/Vehicle',
        body: {
          VehiclePlate: params.plate,
          VehicleState: params.state,
          VehicleAlias: params.vehicleAlias,
          IsDefault: params.isDefaultVehicle ?? false,
        },
      }),
    );
  }

  /** PUT /api/Vehicle — update a vehicle. Success when `Status === 'Success'`. */
  async updateVehicle(
    params: T.UpdateVehicleParams,
  ): Promise<T.VehicleMutationResponse> {
    return this.unwrap(
      this.http.request<T.VehicleMutationResponse>({
        method: 'PUT',
        path: '/api/Vehicle',
        body: {
          VehiclePlate: params.plate,
          VehicleState: params.state,
          VehicleID: params.id,
          VehicleAlias: params.vehicleAlias,
          IsDefault: params.isDefaultVehicle ?? false,
        },
      }),
    );
  }

  /** DELETE /api/Vehicle?VehicleID=… */
  async deleteVehicle(vehicleId: number | string): Promise<void> {
    await this.http.request({
      method: 'DELETE',
      path: '/api/Vehicle',
      query: { VehicleID: vehicleId },
    });
  }

  /* ============================================================== */
  /* Credit cards                                                   */
  /* ============================================================== */

  /** POST /api/Card — add a card. */
  async addCard(params: T.AddCardParams): Promise<unknown> {
    return this.unwrap(
      this.http.request({
        method: 'POST',
        path: '/api/Card',
        body: this.buildCardBody(params),
      }),
    );
  }

  /** POST /api/Card — update a card (full replace). */
  async updateCard(params: T.AddCardParams): Promise<unknown> {
    return this.unwrap(
      this.http.request({
        method: 'POST',
        path: '/api/Card',
        body: this.buildCardBody(params),
      }),
    );
  }

  /** PUT /api/Card — set (or unset) a card as default. */
  async setCardDefault(params: T.SetDefaultCardParams): Promise<unknown> {
    return this.unwrap(
      this.http.request({
        method: 'PUT',
        path: '/api/Card',
        body: { CCID: params.id, CCDefault: params.isDefaultCard },
      }),
    );
  }

  /** DELETE /api/Card?CCID=… */
  async deleteCard(cardId: number | string): Promise<void> {
    await this.http.request({
      method: 'DELETE',
      path: '/api/Card',
      query: { CCID: cardId },
    });
  }

  private buildCardBody(p: T.AddCardParams) {
    return {
      CCNumber: p.cardNumber,
      CCAlias: p.alias,
      CCExpDate: p.expDate,
      CCZip: p.zipCode,
      CCDefault: p.isDefaultCard ?? false,
      ParentCCID: p.id,
    };
  }

  /* ============================================================== */
  /* Meters / zones                                                 */
  /* ============================================================== */

  /** GET /api/Meter?Lat=&Long= — meters near a coordinate. */
  getMetersByLocation(loc: T.LatLng): Promise<T.MetersResponse> {
    return this.unwrap(
      this.http.request<T.MetersResponse>({
        method: 'GET',
        path: '/api/Meter',
        query: { Lat: loc.latitude, Long: loc.longitude },
      }),
    );
  }

  /** GET /api/MeterList?Lat=&Long= — limited meter list near a coordinate. */
  getLimitedMetersByLocation(loc: T.LatLng): Promise<T.MetersResponse> {
    return this.unwrap(
      this.http.request<T.MetersResponse>({
        method: 'GET',
        path: '/api/MeterList',
        query: { Lat: loc.latitude, Long: loc.longitude },
      }),
    );
  }

  /** GET /api/Meter?ZoneName=… */
  getMetersByZoneName(zoneName: string): Promise<T.MetersResponse> {
    return this.unwrap(
      this.http.request<T.MetersResponse>({
        method: 'GET',
        path: '/api/Meter',
        query: { ZoneName: zoneName },
      }),
    );
  }

  /** GET /api/Meter?Query=… — zone or space name search. */
  searchMetersByZoneOrSpace(query: string): Promise<T.MetersResponse> {
    return this.unwrap(
      this.http.request<T.MetersResponse>({
        method: 'GET',
        path: '/api/Meter',
        query: { Query: query },
      }),
    );
  }

  /** GET /api/Meter?TerminalSerNo=… */
  getMetersBySerialNumber(serialNumber: string): Promise<T.MetersResponse> {
    return this.unwrap(
      this.http.request<T.MetersResponse>({
        method: 'GET',
        path: '/api/Meter',
        query: { TerminalSerNo: serialNumber },
      }),
    );
  }

  /** GET /api/Meter?ScannerCode=… (e.g. from a scanned QR/barcode). */
  getMetersByScannerCode(scannerCode: string): Promise<T.MetersResponse> {
    return this.unwrap(
      this.http.request<T.MetersResponse>({
        method: 'GET',
        path: '/api/Meter',
        query: { ScannerCode: scannerCode },
      }),
    );
  }

  /** GET /api/ParkingLogix — nearby parking lots with occupancy. */
  getParkingLots(): Promise<T.ParkingLotsResponse> {
    return this.unwrap(
      this.http.request<T.ParkingLotsResponse>({
        method: 'GET',
        path: '/api/ParkingLogix',
      }),
    );
  }

  /* ============================================================== */
  /* Estimates                                                      */
  /* ============================================================== */

  /** GET /api/ParkingEstimateMulti — multi-duration price ladder. */
  getParkingEstimateMulti(
    params: T.MultiEstimateParams,
  ): Promise<T.MultiEstimateResponse> {
    const query: Record<string, unknown> = {
      ZoneID: params.zoneId,
      SpaceID: params.spaceId,
      CustomerID: params.customerId,
      MinCreditAmount: params.minCreditAmount,
      VehicleID: params.vehicleId,
    };
    if (params.bleEncBytes) query.BleEncBytesString = params.bleEncBytes;
    return this.unwrap(
      this.http.request<T.MultiEstimateResponse>({
        method: 'GET',
        path: '/api/ParkingEstimateMulti',
        query,
      }),
    );
  }

  /** GET /api/ParkingEstimate — single-duration price. */
  getParkingEstimateSingle(
    params: T.SingleEstimateParams,
  ): Promise<T.SingleEstimateResponse> {
    return this.unwrap(
      this.http.request<T.SingleEstimateResponse>({
        method: 'GET',
        path: '/api/ParkingEstimate',
        query: {
          ZoneID: params.zoneId,
          SpaceID: params.spaceId,
          CustomerID: params.customerId,
          ParkingDuration: params.durationInMinutes,
          VehicleID: params.vehicleId,
          CCID: params.creditCardId,
        },
      }),
    );
  }

  /** GET /api/ParkingEstimateItems — item-based price options. */
  getParkingEstimateItems(
    params: T.ItemsEstimateParams,
  ): Promise<T.ItemsEstimateResponse> {
    return this.unwrap(
      this.http.request<T.ItemsEstimateResponse>({
        method: 'GET',
        path: '/api/ParkingEstimateItems',
        query: {
          ZoneID: params.zoneId,
          SpaceID: params.spaceId,
          CustomerID: params.customerId,
          VehicleID: params.vehicleId,
        },
      }),
    );
  }

  /* ============================================================== */
  /* Parking sessions                                               */
  /* ============================================================== */

  /** POST /api/Session — start/pay for a parking session. */
  startParkingSession(
    params: T.StartParkingSessionParams,
  ): Promise<T.StartParkingSessionResponse> {
    const fee = params.transactionFee ?? 0;
    const body: Record<string, unknown> = {
      CCID: String(params.creditCardId),
      Amount: (params.parkingCost + fee).toFixed(2),
      SpaceID: params.spaceId,
      StartTime: params.startTime,
      EndTime: params.endTime,
      CustomerID: params.customerId,
      VehicleID: String(params.vehicleId),
      TimePurchased: String(params.minutesToPurchase),
      ParkingCost: params.parkingCost.toFixed(2),
      TransactionFee: fee.toFixed(2),
      ZoneID: params.zoneId,
      MinCreditAmount:
        params.minCreditAmount != null
          ? params.minCreditAmount.toFixed(2)
          : undefined,
      MeterTypeId: params.meterTypeId,
    };
    if (params.bleEncBytes) body.BleEncBytes = params.bleEncBytes;
    return this.http
      .request<T.StartParkingSessionResponse>({ method: 'POST', path: '/api/Session', body })
      .then((res) => {
        // The server returns HTTP 200 even when the charge is declined, signalling
        // failure via Response.Status:"Error" (+ OriginalErrorMessage like "DECLINED").
        const env = res.data?.Response as { Status?: string; Message?: string } | undefined;
        if (env?.Status === 'Error') {
          throw new Error(
            (env.Message || 'Payment failed.') +
              (res.data?.OriginalErrorMessage ? ` (${res.data.OriginalErrorMessage})` : ''),
          );
        }
        return res.data;
      });
  }

  /** GET /api/ParkingSession — currently active sessions. */
  getActiveParkingSessions(): Promise<T.ActiveSessionsResponse> {
    return this.unwrap(
      this.http.request<T.ActiveSessionsResponse>({
        method: 'GET',
        path: '/api/ParkingSession',
      }),
    );
  }

  /** GET /api/Session — past sessions (paged). */
  getPastParkingSessions(
    params: T.PastSessionsParams = {},
  ): Promise<T.PastSessionsResponse> {
    return this.unwrap(
      this.http.request<T.PastSessionsResponse>({
        method: 'GET',
        path: '/api/Session',
        query: {
          CurrentPage: params.currentPage ?? '',
          PageSize: params.pageSize ?? '',
        },
      }),
    );
  }

  /* ============================================================== */
  /* Receipts                                                       */
  /* ============================================================== */

  /** GET /api/ParkingReceipt?TransactionID=… */
  getParkingReceipt(transactionId: number | string): Promise<T.ReceiptResponse> {
    return this.unwrap(
      this.http.request<T.ReceiptResponse>({
        method: 'GET',
        path: '/api/ParkingReceipt',
        query: { TransactionID: transactionId },
      }),
    );
  }

  /** POST /api/ParkingReceipt?Id=… — email a receipt to the account holder. */
  async emailParkingReceipt(receiptId: number | string): Promise<void> {
    await this.http.request({
      method: 'POST',
      path: '/api/ParkingReceipt',
      query: { Id: receiptId },
    });
  }

  /* ============================================================== */
  /* Notification settings                                          */
  /* ============================================================== */

  /** GET /api/Setting — raw notification settings bitmask. */
  getNotificationSettings(): Promise<T.RawSettingsResponse> {
    return this.unwrap(
      this.http.request<T.RawSettingsResponse>({
        method: 'GET',
        path: '/api/Setting',
      }),
    );
  }

  /**
   * POST /api/Setting — update notification settings.
   * `userSettings` is the numeric bitmask (as a string) the app computes via reduceSettings.
   */
  async setNotificationSettings(userSettings: string | number): Promise<void> {
    await this.http.request({
      method: 'POST',
      path: '/api/Setting',
      body: { UserSettings: String(userSettings) },
    });
  }

  /* ============================================================== */
  /* Static content                                                 */
  /* ============================================================== */

  /**
   * GET /api/State?ID=… — states/regions under a parent id.
   * The `ID` param is required — the route 404s without it (confirmed against prod).
   */
  getStates(id: number | string): Promise<T.StatesResponse> {
    return this.unwrap(
      this.http.request<T.StatesResponse>({
        method: 'GET',
        path: '/api/State',
        query: { ID: id },
        includeAuthToken: false,
      }),
    );
  }

  /** GET /api/ParkSmarterAbout — `{ Value }`. */
  getAbout(): Promise<T.ValueResponse> {
    return this.unwrap(
      this.http.request<T.ValueResponse>({
        method: 'GET',
        path: '/api/ParkSmarterAbout',
        includeAuthToken: false,
      }),
    );
  }

  /** GET /api/ParkSmarterFAQ — `{ FAQs: [...] }`. */
  getFAQ(): Promise<T.FAQResponse> {
    return this.unwrap(
      this.http.request<T.FAQResponse>({
        method: 'GET',
        path: '/api/ParkSmarterFAQ',
        includeAuthToken: false,
      }),
    );
  }

  /** GET /api/ParkSmarterPrivacyPolicies — `{ Value }`. */
  getPrivacyPolicy(): Promise<T.ValueResponse> {
    return this.unwrap(
      this.http.request<T.ValueResponse>({
        method: 'GET',
        path: '/api/ParkSmarterPrivacyPolicies',
        includeAuthToken: false,
      }),
    );
  }

  /** GET /api/ParkSmarterTerms — `{ Value }`. */
  getTerms(): Promise<T.ValueResponse> {
    return this.unwrap(
      this.http.request<T.ValueResponse>({
        method: 'GET',
        path: '/api/ParkSmarterTerms',
        includeAuthToken: false,
      }),
    );
  }
}
