import fetch from "node-fetch";
import {
  AddressTrackingResult,
  TrackedAddressRecord,
  TrackedAddressRegistration,
  WebhookShardDefinition,
  WebhookShardMetadata,
} from "../types";
import { IAddressTrackerPort } from "../providers/ProviderPorts";
import { ITrackingStore } from "./StoreContracts";

export class AlchemyWebhookAddressService implements IAddressTrackerPort {
  private readonly shardDefinitionsByChainType: Record<string, WebhookShardDefinition[]>;
  private readonly defaultShardCapacity: number;

  constructor(
    private authToken: string | undefined,
    shardDefinitionsRaw: string | undefined,
    private trackingStoreService: ITrackingStore,
    defaultShardCapacity: number = 100000
  ) {
    this.defaultShardCapacity = defaultShardCapacity;
    this.shardDefinitionsByChainType = this.parseShardMap(shardDefinitionsRaw, defaultShardCapacity);
  }

  async initialize(): Promise<void> {
    await this.seedShardMetadata();
  }

  isEnabled(): boolean {
    return Boolean(this.authToken && Object.keys(this.shardDefinitionsByChainType).length > 0);
  }

  async getTrackingMetadata(): Promise<WebhookShardMetadata[]> {
    return this.trackingStoreService.getAllShards();
  }

  async addAddresses(registrations: TrackedAddressRegistration[]): Promise<AddressTrackingResult> {
    if (!this.isEnabled()) {
      return { tracked: 0, skipped: registrations.length, overflow: 0, assignments: [] };
    }

    const grouped = new Map<string, Set<string>>();
    let skipped = 0;
    let overflow = 0;
    const assignments: TrackedAddressRecord[] = [];

    for (const registration of registrations) {
      const chainType = registration.wallet.chain_type;

      const normalizedAddress = this.normalizeAddress(registration.wallet.address);
      const existingAssignment = await this.trackingStoreService.getAddress(normalizedAddress);
      if (existingAssignment) {
        if (existingAssignment.user_id !== registration.user_id) {
          throw new Error(`Address ${registration.wallet.address} is already tracked for another user`);
        }

        skipped += 1;
        continue;
      }

      const targetShard = await this.selectShard(chainType, grouped);
      if (!targetShard) {
        overflow += 1;
        continue;
      }

      const addresses = grouped.get(targetShard.webhook_id) || new Set<string>();
      addresses.add(normalizedAddress);
      grouped.set(targetShard.webhook_id, addresses);

      const now = new Date().toISOString();
      assignments.push({
        address: registration.wallet.address,
        normalized_address: normalizedAddress,
        user_id: registration.user_id,
        chain_type: chainType,
        wallet_id: registration.wallet.wallet_id || null,
        webhook_id: targetShard.webhook_id,
        shard_key: targetShard.shard_key,
        created_at: now,
        updated_at: now,
      });
    }

    let tracked = 0;
    for (const [webhookId, addresses] of grouped.entries()) {
      await this.request(webhookId, Array.from(addresses));
      tracked += addresses.size;
    }

    await this.trackingStoreService.registerAssignments(assignments);

    return {
      tracked,
      skipped,
      overflow,
      assignments,
    };
  }

  async removeAddresses(addresses: string[]): Promise<{ removed: number; skipped: number }> {
    if (!this.isEnabled()) {
      return { removed: 0, skipped: addresses.length };
    }

    const grouped = new Map<string, Set<string>>();
    const normalizedAddressesToRemove: string[] = [];
    let skipped = 0;

    for (const address of addresses) {
      const normalizedAddress = this.normalizeAddress(address);
      const existingAssignment = await this.trackingStoreService.getAddress(normalizedAddress);

      if (!existingAssignment) {
        skipped += 1;
        continue;
      }

      const shardAddresses = grouped.get(existingAssignment.webhook_id) || new Set<string>();
      shardAddresses.add(normalizedAddress);
      grouped.set(existingAssignment.webhook_id, shardAddresses);
      normalizedAddressesToRemove.push(normalizedAddress);
    }

    let removed = 0;
    for (const [webhookId, shardAddresses] of grouped.entries()) {
      const removals = Array.from(shardAddresses);
      await this.request(webhookId, [], removals);
      removed += removals.length;
    }

    await this.trackingStoreService.unregisterAddresses(normalizedAddressesToRemove);

    return { removed, skipped };
  }

  private async request(webhookId: string, addressesToAdd: string[], addressesToRemove: string[] = []): Promise<void> {
    const response = await fetch("https://dashboard.alchemy.com/api/update-webhook-addresses", {
      method: "PATCH",
      headers: {
        "content-type": "application/json",
        "x-alchemy-token": this.authToken as string,
      },
      body: JSON.stringify({
        webhook_id: webhookId,
        addresses_to_add: addressesToAdd,
        addresses_to_remove: addressesToRemove,
      }),
    });

    if (!response.ok) {
      throw new Error(`Alchemy webhook address update failed with status ${response.status}`);
    }
  }

  private async selectShard(
    chainType: string,
    pendingAssignments: Map<string, Set<string>>
  ): Promise<WebhookShardMetadata | null> {
    const shards = await this.trackingStoreService.getShardsByChainType(chainType);
    for (const shard of shards) {
      const pendingCount = pendingAssignments.get(shard.webhook_id)?.size || 0;
      if (shard.address_count + pendingCount < shard.capacity) {
        return shard;
      }
    }

    return null;
  }

  private async seedShardMetadata(): Promise<void> {
    const shards: WebhookShardMetadata[] = [];
    for (const [chainType, definitions] of Object.entries(this.shardDefinitionsByChainType)) {
      definitions.forEach((definition, index) => {
        const shardKey = `${chainType}::${definition.webhook_id}`;
        shards.push({
          shard_key: shardKey,
          webhook_id: definition.webhook_id,
          chain_type: chainType,
          capacity: definition.capacity || this.defaultShardCapacity,
          address_count: 0,
          addresses: [],
          label: definition.label || `${chainType}-shard-${index + 1}`,
          last_synced_at: null,
        });
      });
    }

    await this.trackingStoreService.upsertShards(shards);
  }

  private parseShardMap(
    raw: string | undefined,
    defaultShardCapacity: number
  ): Record<string, WebhookShardDefinition[]> {
    if (!raw) {
      return {};
    }

    try {
      const parsed = JSON.parse(raw) as Record<string, unknown>;
      return Object.entries(parsed).reduce<Record<string, WebhookShardDefinition[]>>((accumulator, [chainType, value]) => {
        if (typeof value === "string" && value.trim()) {
          accumulator[chainType] = [{ webhook_id: value.trim(), capacity: defaultShardCapacity }];
          return accumulator;
        }

        if (Array.isArray(value)) {
          const shards = value
            .filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object")
            .map((item) => {
              const webhookId = typeof item.webhook_id === "string" ? item.webhook_id.trim() : "";
              if (!webhookId) {
                throw new Error(`Missing webhook_id for shard '${chainType}'`);
              }

              return {
                webhook_id: webhookId,
                capacity: typeof item.capacity === "number" && item.capacity > 0
                  ? item.capacity
                  : defaultShardCapacity,
                label: typeof item.label === "string" ? item.label.trim() : null,
              } satisfies WebhookShardDefinition;
            });

          if (shards.length > 0) {
            accumulator[chainType] = shards;
          }
        }

        return accumulator;
      }, {});
    } catch {
      throw new Error("ALCHEMY_WEBHOOK_SHARDS must be valid JSON");
    }
  }

  private normalizeAddress(address: string): string {
    return address.startsWith("0x") || address.startsWith("0X") ? address.toLowerCase() : address;
  }
}
