import { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys";
import { bytesToBase64Url, textToBytes } from "./encoding";
import { DEFAULT_KEYS_STORAGE_KEY, loadKeys, saveKeys } from "./storage";
import type { AssetKind, AssetRecord, FeatureAsset, InvitationPayload, StorageLike, StoredKeys } from "./types";

type RequestInitLike = Omit<RequestInit, "body"> & { body?: unknown };

type FeatureProperties = {
  assets?: FeatureAsset[];
  [key: string]: unknown;
};

type GeoFeature = {
  id: string;
  geometry?: {
    coordinates?: number[];
  };
  properties?: FeatureProperties;
};

/**
 * TypeScript API client for Momswap Geo backend.
 * Handles Ed25519 key storage, auth flows, and GeoJSON collection/feature CRUD.
 */
export class GeoApiClient {
  private readonly baseUrl: string;
  private readonly storage: StorageLike;
  private readonly storageKey: string;
  private accessToken: string | null = null;

  /**
   * @param baseUrl - API base URL (e.g. https://tenerife.baby)
   * @param storage - Storage adapter (localStorage-like: getItem, setItem, removeItem)
   * @param storageKey - Key for persisting keypair (default from DEFAULT_KEYS_STORAGE_KEY)
   */
  constructor(baseUrl: string, storage: StorageLike, storageKey = DEFAULT_KEYS_STORAGE_KEY) {
    this.baseUrl = baseUrl.replace(/\/+$/g, "");
    this.storage = storage;
    this.storageKey = storageKey;
  }

  /**
   * Ensure a keypair exists in storage. If none found, generates a new Ed25519 keypair and saves it.
   * Use on app init so the user always has keys ready for auth.
   */
  async ensureKeysInStorage(): Promise<StoredKeys> {
    const existing = loadKeys(this.storage, this.storageKey);
    if (existing) {
      return existing;
    }
    const generated = await generateKeyPair();
    saveKeys(this.storage, generated, this.storageKey);
    return generated;
  }

  /** Get the stored keypair without generating. Returns null if none stored. */
  getStoredKeys(): StoredKeys | null {
    return loadKeys(this.storage, this.storageKey);
  }

  /**
   * Derive public key from private key (Ed25519).
   * Use when importing a private key from backup/QR to restore the public key.
   */
  async derivePublicKey(privateKey: string): Promise<string> {
    return getPublicKeyFromPrivate(privateKey);
  }

  /** Overwrite stored keypair (e.g. after import from backup or restore from QR). */
  importKeys(keys: StoredKeys): void {
    saveKeys(this.storage, keys, this.storageKey);
  }

  /** Read stored keypair for export/backup. Does not modify storage. */
  exportKeys(): StoredKeys | null {
    return loadKeys(this.storage, this.storageKey);
  }

  /** Set bearer token for authenticated requests. Call after loginWithSignature. */
  setAccessToken(token: string | null): void {
    this.accessToken = token;
  }

  private async request<T>(path: string, init: RequestInitLike = {}): Promise<T> {
    const headers = new Headers(init.headers ?? {});
    headers.set("Content-Type", "application/json");
    if (this.accessToken) {
      headers.set("Authorization", `Bearer ${this.accessToken}`);
    }
    const body = init.body === undefined ? undefined : JSON.stringify(init.body);
    const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, body });
    if (!res.ok) {
      const maybeJson = (await res.json().catch(() => ({}))) as { error?: string; hint?: string; code?: string };
      let msg = maybeJson.error ?? `HTTP ${res.status}`;
      if (maybeJson.hint) msg += `. ${maybeJson.hint}`;
      throw new Error(msg);
    }
    if (res.status === 204) {
      return undefined as T;
    }
    return (await res.json()) as T;
  }

  /**
   * Fetch the API service public key. Used for registerBySigningServiceKey
   * and for clients that need to verify server responses.
   */
  async getServicePublicKey(): Promise<{ publicKey: string }> {
    return this.request("/v1/service-key", { method: "GET" });
  }

  /**
   * Request a login challenge. Returns nonce and messageToSign.
   * Used internally by loginWithSignature; or for custom login flows.
   */
  async createChallenge(publicKey: string): Promise<{ nonce: string; messageToSign: string }> {
    return this.request("/v1/auth/challenge", { method: "POST", body: { publicKey } });
  }

  /**
   * Register without invitation by signing the API service public key.
   * Use when SERVICE_PUBLIC_KEY/ADMIN_PUBLIC_KEY is set. Throws 409 if already registered.
   */
  async registerBySigningServiceKey(publicKey: string, privateKey: string): Promise<void> {
    const { publicKey: servicePublicKey } = await this.getServicePublicKey();
    const signature = await signMessage(privateKey, servicePublicKey);
    await this.request("/v1/auth/register-by-signature", {
      method: "POST",
      body: { publicKey, signature },
    });
  }

  /**
   * Login via challenge-response. Signs the login nonce, returns bearer token.
   * Stores token internally; call setAccessToken if you need it elsewhere.
   * Requires user to be registered first (registerBySigningServiceKey or registerWithInvitation).
   */
  async loginWithSignature(publicKey: string, privateKey: string): Promise<string> {
    const challenge = await this.createChallenge(publicKey);
    const signature = await signMessage(privateKey, challenge.messageToSign);
    const response = await this.request<{ accessToken: string }>("/v1/auth/login", {
      method: "POST",
      body: {
        publicKey,
        nonce: challenge.nonce,
        signature,
      },
    });
    this.accessToken = response.accessToken;
    return response.accessToken;
  }

  /**
   * Create an invitation for new users. Authenticated inviter signs the payload.
   * Use when invitation-based onboarding is required (no register-by-signature).
   */
  async createInvitation(payload: InvitationPayload, inviterPrivateKey: string): Promise<void> {
    const payloadStr = JSON.stringify(payload);
    const payloadB64 = bytesToBase64Url(textToBytes(payloadStr));
    const inviteSignature = await signMessage(inviterPrivateKey, `invite:${payloadB64}`);
    await this.request("/v1/invitations", {
      method: "POST",
      body: {
        invitePayloadB64: payloadB64,
        inviteSignature,
      },
    });
  }

  /**
   * Register using an invitation. Proves ownership of keys and redeems the invite.
   * Use when user has received an invitation (e.g. from createInvitation).
   */
  async registerWithInvitation(input: {
    publicKey: string;
    privateKey: string;
    invitePayloadB64: string;
    inviteSignature: string;
    jti: string;
  }): Promise<void> {
    const proofSignature = await signMessage(input.privateKey, `register:${input.publicKey}:${input.jti}`);
    await this.request("/v1/auth/register", {
      method: "POST",
      body: {
        publicKey: input.publicKey,
        invitePayloadB64: input.invitePayloadB64,
        inviteSignature: input.inviteSignature,
        proofSignature,
      },
    });
  }

  /** List collections for the authenticated user. Requires login first. */
  async listCollections(): Promise<{ collections: Array<{ id: string; name: string }> }> {
    return this.request("/v1/collections", { method: "GET" });
  }

  /** Create a new collection. Returns id and name. */
  async createCollection(name: string): Promise<{ id: string; name: string }> {
    return this.request("/v1/collections", { method: "POST", body: { name } });
  }

  /** Rename a collection. Must own it. */
  async updateCollection(collectionId: string, name: string): Promise<{ id: string; name: string }> {
    return this.request(`/v1/collections/${collectionId}`, {
      method: "PATCH",
      body: { name },
    });
  }

  /** Delete a collection and its features. Must own it. */
  async deleteCollection(collectionId: string): Promise<void> {
    return this.request(`/v1/collections/${collectionId}`, { method: "DELETE" });
  }

  /** List GeoJSON features in a collection. Must own the collection. */
  async listFeatures(collectionId: string): Promise<{ features: GeoFeature[] }> {
    return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" });
  }

  /**
   * Add a Point feature to a collection. lon ∈ [-180,180], lat ∈ [-90,90].
   * properties can include name, etc. Returns the new feature id.
   */
  async createPointFeature(
    collectionId: string,
    lon: number,
    lat: number,
    properties: Record<string, unknown>
  ): Promise<{ id: string }> {
    return this.request(`/v1/collections/${collectionId}/features`, {
      method: "POST",
      body: {
        geometry: { type: "Point", coordinates: [lon, lat] },
        properties,
      },
    });
  }

  /** Delete a feature. Must own it. */
  async deleteFeature(featureId: string): Promise<void> {
    return this.request(`/v1/features/${featureId}`, { method: "DELETE" });
  }

  /**
   * Create or reuse an asset by checksum+ext for the authenticated owner and link it to a feature.
   * If checksum/ext already exists for this owner, backend returns existing asset and refreshes link metadata.
   */
  async createOrLinkAsset(input: {
    featureId: string;
    checksum: string;
    ext: string;
    kind: AssetKind;
    mimeType?: string;
    sizeBytes?: number;
    name?: string;
    description?: string;
    isPublic?: boolean;
  }): Promise<{ asset: AssetRecord; link: string }> {
    return this.request("/v1/assets", { method: "POST", body: input });
  }

  /**
   * Request a backend upload URL for an existing asset.
   * Backend returns a service URL (for example /v1/assets/{id}/upload), not a direct storage endpoint.
   */
  async getAssetSignedUploadUrl(
    assetId: string,
    contentType?: string
  ): Promise<{ url: string; method: string }> {
    const response = await this.request<{ url: string; method: string }>(`/v1/assets/${assetId}/signed-upload`, {
      method: "POST",
      body: { contentType: contentType ?? "application/octet-stream" },
    });
    if (response.url.startsWith("/")) {
      response.url = this.resolveRelativeLink(response.url);
    }
    return response;
  }

  /**
   * Upload file/binary for an existing asset through backend upload endpoint.
   * Uses getAssetSignedUploadUrl internally and executes the upload request.
   */
  async uploadAssetBinary(
    assetId: string,
    payload: BodyInit,
    contentType = "application/octet-stream"
  ): Promise<void> {
    const upload = await this.getAssetSignedUploadUrl(assetId, contentType);
    const headers = new Headers();
    if (contentType) {
      headers.set("Content-Type", contentType);
    }
    if (this.accessToken) {
      headers.set("Authorization", `Bearer ${this.accessToken}`);
    }
    const res = await fetch(upload.url, {
      method: upload.method || "PUT",
      headers,
      body: payload,
    });
    if (!res.ok) {
      const maybeJson = (await res.json().catch(() => ({}))) as { error?: string; hint?: string };
      let msg = maybeJson.error ?? `Upload failed (${res.status})`;
      if (maybeJson.hint) msg += `. ${maybeJson.hint}`;
      throw new Error(msg);
    }
  }

  /** Update asset visibility (owner only). */
  async setAssetVisibility(assetId: string, isPublic: boolean): Promise<{ asset: AssetRecord; link: string }> {
    return this.request(`/v1/assets/${assetId}`, {
      method: "PATCH",
      body: { isPublic },
    });
  }

  /** Build absolute download URL from service-relative link returned in feature assets. */
  resolveRelativeLink(path: string): string {
    if (!path.startsWith("/")) return `${this.baseUrl}/${path}`;
    return `${this.baseUrl}${path}`;
  }
}
