import * as anchor from '@project-serum/anchor';
import { EventParser } from '@project-serum/anchor';
import type { Wallet } from '@project-serum/anchor';
import type { Connection, Keypair, PublicKey } from '@solana/web3.js';

import { sleep, waitForFinality, Wallet_ } from '../utils';
import { ENCRYPTION_OVERHEAD_BYTES } from '../utils/ecdh-encryption';
import { CyclicByteBuffer } from '../utils/cyclic-bytebuffer';
import ByteBuffer from 'bytebuffer';
import { EncryptionProps, TextSerdeFactory } from './text-serde';

// TODO: Switch from types to classes

/*
User metadata
*/
// TODO: Remove device token consts here
export const DEVICE_TOKEN_LENGTH = 64;
export const DEVICE_TOKEN_PAYLOAD_LENGTH = 128;
export const DEVICE_TOKEN_PADDING_LENGTH =
  DEVICE_TOKEN_PAYLOAD_LENGTH - DEVICE_TOKEN_LENGTH - ENCRYPTION_OVERHEAD_BYTES;

const ACCOUNT_DESCRIPTOR_SIZE = 8;
const DIALECT_ACCOUNT_MEMBER_SIZE = 34;
const DIALECT_ACCOUNT_MEMBER0_OFFSET = ACCOUNT_DESCRIPTOR_SIZE;
const DIALECT_ACCOUNT_MEMBER1_OFFSET =
  DIALECT_ACCOUNT_MEMBER0_OFFSET + DIALECT_ACCOUNT_MEMBER_SIZE;

export type Subscription = {
  pubkey: PublicKey;
  enabled: boolean;
};

type RawDialect = {
  members: Member[];
  messages: RawCyclicByteBuffer;
  lastMessageTimestamp: number;
  encrypted: boolean;
};

type RawCyclicByteBuffer = {
  readOffset: number;
  writeOffset: number;
  itemsCount: number;
  buffer: Uint8Array;
};

export type Metadata = {
  subscriptions: Subscription[];
};

export type DialectAccount = {
  dialect: Dialect;
  publicKey: PublicKey;
};

export type Dialect = {
  members: Member[];
  messages: Message[];
  nextMessageIdx: number;
  lastMessageTimestamp: number;
  encrypted: boolean;
};

export type Message = {
  owner: PublicKey;
  text: string;
  timestamp: number;
};

export type FindDialectQuery = {
  userPk?: anchor.web3.PublicKey;
};

export function isDialectAdmin(
  dialect: DialectAccount,
  user: anchor.web3.PublicKey,
): boolean {
  return dialect.dialect.members.some(
    (m) => m.publicKey.equals(user) && m.scopes[0],
  );
}

export async function accountInfoGet(
  connection: Connection,
  publicKey: PublicKey,
): Promise<anchor.web3.AccountInfo<Buffer> | null> {
  return await connection.getAccountInfo(publicKey);
}

export async function accountInfoFetch(
  _url: string,
  connection: Connection,
  publicKeyStr: string,
): Promise<anchor.web3.AccountInfo<Buffer> | null> {
  const publicKey = new anchor.web3.PublicKey(publicKeyStr);
  return await accountInfoGet(connection, publicKey);
}

export function ownerFetcher(
  _url: string,
  wallet: Wallet_,
  connection: Connection,
): Promise<anchor.web3.AccountInfo<Buffer> | null> {
  return accountInfoGet(connection, wallet.publicKey);
}

export async function getMetadataProgramAddress(
  program: anchor.Program,
  user: PublicKey,
): Promise<[anchor.web3.PublicKey, number]> {
  return await anchor.web3.PublicKey.findProgramAddress(
    [Buffer.from('metadata'), user.toBuffer()],
    program.programId,
  );
}

// TODO: Simplify this function further now that we're no longer decrypting the device token.
export async function getMetadata(
  program: anchor.Program,
  user: PublicKey | anchor.web3.Keypair,
  otherParty?: PublicKey | anchor.web3.Keypair | null,
): Promise<Metadata> {
  let shouldDecrypt = false;
  let userIsKeypair = false;
  let otherPartyIsKeypair = false;

  try {
    // assume user is pubkey
    new anchor.web3.PublicKey(user.toString());
  } catch {
    // user is keypair
    userIsKeypair = true;
  }

  try {
    // assume otherParty is pubkey
    new anchor.web3.PublicKey(otherParty?.toString() || '');
  } catch {
    // otherParty is keypair or null
    otherPartyIsKeypair = (otherParty && true) || false;
  }

  if (otherParty && (userIsKeypair || otherPartyIsKeypair)) {
    // cases 3 - 5
    shouldDecrypt = true;
  }

  const [metadataAddress] = await getMetadataProgramAddress(
    program,
    userIsKeypair ? (user as Keypair).publicKey : (user as PublicKey),
  );
  const metadata = await program.account.metadataAccount.fetch(metadataAddress);

  // TODO RM this code chunk and change function signature
  return {
    subscriptions: metadata.subscriptions.filter(
      (s: Subscription) => !s.pubkey.equals(anchor.web3.PublicKey.default),
    ),
  };
}

export async function createMetadata(
  program: anchor.Program,
  user: anchor.web3.Keypair | Wallet,
): Promise<Metadata> {
  const [metadataAddress, metadataNonce] = await getMetadataProgramAddress(
    program,
    user.publicKey,
  );
  const tx = await program.rpc.createMetadata(new anchor.BN(metadataNonce), {
    accounts: {
      user: user.publicKey,
      metadata: metadataAddress,
      rent: anchor.web3.SYSVAR_RENT_PUBKEY,
      systemProgram: anchor.web3.SystemProgram.programId,
    },
    signers: 'secretKey' in user ? [user] : [],
  });
  await waitForFinality(program, tx);
  return await getMetadata(program, user.publicKey);
}

export async function deleteMetadata(
  program: anchor.Program,
  user: anchor.web3.Keypair | Wallet,
): Promise<void> {
  const [metadataAddress, metadataNonce] = await getMetadataProgramAddress(
    program,
    user.publicKey,
  );
  await program.rpc.closeMetadata(new anchor.BN(metadataNonce), {
    accounts: {
      user: user.publicKey,
      metadata: metadataAddress,
      rent: anchor.web3.SYSVAR_RENT_PUBKEY,
      systemProgram: anchor.web3.SystemProgram.programId,
    },
    signers: 'secretKey' in user ? [user] : [],
  });
}

export async function subscribeUser(
  program: anchor.Program,
  dialect: DialectAccount,
  user: PublicKey,
  signer: Keypair,
): Promise<Metadata> {
  const [publicKey, nonce] = await getDialectProgramAddress(
    program,
    dialect.dialect.members,
  );
  const [metadata, metadataNonce] = await getMetadataProgramAddress(
    program,
    user,
  );
  const tx = await program.rpc.subscribeUser(
    new anchor.BN(nonce),
    new anchor.BN(metadataNonce),
    {
      accounts: {
        dialect: publicKey,
        signer: signer.publicKey,
        user: user,
        metadata,
        rent: anchor.web3.SYSVAR_RENT_PUBKEY,
        systemProgram: anchor.web3.SystemProgram.programId,
      },
      signers: [signer],
    },
  );
  await waitForFinality(program, tx);
  return await getMetadata(program, user);
}

/*
Dialect
*/

export async function getDialectProgramAddress(
  program: anchor.Program,
  members: Member[],
): Promise<[anchor.web3.PublicKey, number]> {
  return await anchor.web3.PublicKey.findProgramAddress(
    [
      Buffer.from('dialect'),
      ...members // sort for deterministic PDA
        .map((m) => m.publicKey.toBuffer())
        .sort((a, b) => a.compare(b)), // TODO: test that buffers sort as expected
    ],
    program.programId,
  );
}

function parseMessages(
  { messages: rawMessagesBuffer, members, encrypted }: RawDialect,
  encryptionProps?: EncryptionProps,
) {
  if (encrypted && !encryptionProps) {
    return [];
  }
  const messagesBuffer = new CyclicByteBuffer(
    rawMessagesBuffer.readOffset,
    rawMessagesBuffer.writeOffset,
    rawMessagesBuffer.itemsCount,
    rawMessagesBuffer.buffer,
  );
  const textSerde = TextSerdeFactory.create(
    {
      encrypted,
      members,
    },
    encryptionProps,
  );
  const allMessages: Message[] = messagesBuffer.items().map(({ buffer }) => {
    const byteBuffer = new ByteBuffer(buffer.length).append(buffer).flip();
    const ownerMemberIndex = byteBuffer.readByte();
    const messageOwner = members[ownerMemberIndex];
    const timestamp = byteBuffer.readUint32() * 1000;
    const serializedText = new Uint8Array(byteBuffer.toBuffer(true));
    const text = textSerde.deserialize(serializedText);
    return {
      owner: messageOwner.publicKey,
      text,
      timestamp: timestamp,
    };
  });
  return allMessages.reverse();
}

function parseRawDialect(
  rawDialect: RawDialect,
  encryptionProps?: EncryptionProps,
) {
  return {
    encrypted: rawDialect.encrypted,
    members: rawDialect.members,
    nextMessageIdx: rawDialect.messages.writeOffset,
    lastMessageTimestamp: rawDialect.lastMessageTimestamp * 1000,
    messages: parseMessages(rawDialect, encryptionProps),
  };
}

export async function getDialect(
  program: anchor.Program,
  publicKey: PublicKey,
  encryptionProps?: EncryptionProps,
): Promise<DialectAccount> {
  const rawDialect = (await program.account.dialectAccount.fetch(
    publicKey,
  )) as RawDialect;
  const account = await program.provider.connection.getAccountInfo(publicKey);
  const dialect = parseRawDialect(rawDialect, encryptionProps);
  return {
    ...account,
    publicKey: publicKey,
    dialect,
  } as DialectAccount;
}

export async function getDialects(
  program: anchor.Program,
  user: anchor.web3.Keypair | Wallet,
  encryptionProps?: EncryptionProps,
): Promise<DialectAccount[]> {
  const metadata = await getMetadata(program, user.publicKey);
  const enabledSubscriptions = metadata.subscriptions.filter(
    (it) => it.enabled,
  );
  return Promise.all(
    enabledSubscriptions.map(async ({ pubkey }) =>
      getDialect(program, pubkey, encryptionProps),
    ),
  ).then((dialects) =>
    dialects.sort(
      ({ dialect: d1 }, { dialect: d2 }) =>
        d2.lastMessageTimestamp - d1.lastMessageTimestamp,
    ),
  );
}

export async function getDialectForMembers(
  program: anchor.Program,
  members: Member[],
  encryptionProps?: EncryptionProps,
): Promise<DialectAccount> {
  const sortedMembers = members.sort((a, b) =>
    a.publicKey.toBuffer().compare(b.publicKey.toBuffer()),
  );
  const [publicKey] = await getDialectProgramAddress(program, sortedMembers);
  return await getDialect(program, publicKey, encryptionProps);
}

export async function findDialects(
  program: anchor.Program,
  { userPk }: FindDialectQuery,
): Promise<DialectAccount[]> {
  const memberFilters = userPk
    ? [
        {
          memcmp: {
            offset: DIALECT_ACCOUNT_MEMBER0_OFFSET,
            bytes: userPk.toBase58(),
          },
        },
        {
          memcmp: {
            offset: DIALECT_ACCOUNT_MEMBER1_OFFSET,
            bytes: userPk.toBase58(),
          },
        },
      ]
    : [];
  return Promise.all(
    memberFilters.map((it) => program.account.dialectAccount.all([it])),
  )
    .then((it) =>
      it.flat().map((a) => {
        const rawDialect = a.account as RawDialect;
        const dialectAccount: DialectAccount = {
          publicKey: a.publicKey,
          dialect: parseRawDialect(rawDialect),
        };
        return dialectAccount;
      }),
    )
    .then((dialects) =>
      dialects.sort(
        ({ dialect: d1 }, { dialect: d2 }) =>
          d2.lastMessageTimestamp - d1.lastMessageTimestamp, // descending
      ),
    );
}

export async function createDialect(
  program: anchor.Program,
  owner: anchor.web3.Keypair | Wallet,
  members: Member[],
  encrypted = false,
  encryptionProps?: EncryptionProps,
): Promise<DialectAccount> {
  const sortedMembers = members.sort((a, b) =>
    a.publicKey.toBuffer().compare(b.publicKey.toBuffer()),
  );
  const [publicKey, nonce] = await getDialectProgramAddress(
    program,
    sortedMembers,
  );
  // TODO: assert owner in members
  const keyedMembers = sortedMembers.reduce(
    (ms, m, idx) => ({ ...ms, [`member${idx}`]: m.publicKey }),
    {},
  );
  const tx = await program.rpc.createDialect(
    new anchor.BN(nonce),
    encrypted,
    sortedMembers.map((m) => m.scopes),
    {
      accounts: {
        dialect: publicKey,
        owner: owner.publicKey,
        ...keyedMembers,
        rent: anchor.web3.SYSVAR_RENT_PUBKEY,
        systemProgram: anchor.web3.SystemProgram.programId,
      },
      signers: 'secretKey' in owner ? [owner] : [],
    },
  );
  await waitForFinality(program, tx);
  return await getDialectForMembers(program, members, encryptionProps);
}

export async function deleteDialect(
  program: anchor.Program,
  { dialect }: DialectAccount,
  owner: anchor.web3.Keypair | Wallet,
): Promise<void> {
  const [dialectPublicKey, nonce] = await getDialectProgramAddress(
    program,
    dialect.members,
  );
  await program.rpc.closeDialect(new anchor.BN(nonce), {
    accounts: {
      dialect: dialectPublicKey,
      owner: owner.publicKey,
      rent: anchor.web3.SYSVAR_RENT_PUBKEY,
      systemProgram: anchor.web3.SystemProgram.programId,
    },
    signers: 'secretKey' in owner ? [owner] : [],
  });
}

/*
Members
*/

export type Member = {
  publicKey: anchor.web3.PublicKey;
  scopes: [boolean, boolean];
};

/*
Messages
*/

export async function sendMessage(
  program: anchor.Program,
  { dialect, publicKey }: DialectAccount,
  sender: anchor.web3.Keypair | Wallet,
  text: string,
  encryptionProps?: EncryptionProps,
): Promise<Message> {
  const [dialectPublicKey, nonce] = await getDialectProgramAddress(
    program,
    dialect.members,
  );
  const textSerde = TextSerdeFactory.create(
    {
      encrypted: dialect.encrypted,
      members: dialect.members,
    },
    encryptionProps,
  );
  const serializedText = textSerde.serialize(text);
  await program.rpc.sendMessage(
    new anchor.BN(nonce),
    Buffer.from(serializedText),
    {
      accounts: {
        dialect: dialectPublicKey,
        sender: sender ? sender.publicKey : program.provider.wallet.publicKey,
        member0: dialect.members[0].publicKey,
        member1: dialect.members[1].publicKey,
        rent: anchor.web3.SYSVAR_RENT_PUBKEY,
        systemProgram: anchor.web3.SystemProgram.programId,
      },
      signers: sender && 'secretKey' in sender ? [sender] : [],
    },
  );
  const d = await getDialect(program, publicKey, encryptionProps);
  return d.dialect.messages[d.dialect.nextMessageIdx - 1]; // TODO: Support ring
}

// Events
// An event is something that has happened in the past
export type Event =
  | DialectCreatedEvent
  | DialectDeletedEvent
  | MetadataCreatedEvent
  | MetadataDeletedEvent
  | MessageSentEvent
  | UserSubscribedEvent;

export interface DialectCreatedEvent {
  type: 'dialect-created';
  dialect: PublicKey;
  members: PublicKey[];
}

export interface DialectDeletedEvent {
  type: 'dialect-deleted';
  dialect: PublicKey;
  members: PublicKey[];
}

export interface MetadataCreatedEvent {
  type: 'metadata-created';
  metadata: PublicKey;
  user: PublicKey;
}

export interface MetadataDeletedEvent {
  type: 'metadata-deleted';
  metadata: PublicKey;
  user: PublicKey;
}

export interface MessageSentEvent {
  type: 'message-sent';
  dialect: PublicKey;
  sender: PublicKey;
}

export interface UserSubscribedEvent {
  type: 'user-subscribed';
  metadata: PublicKey;
  dialect: PublicKey;
}

export type EventHandler = (event: Event) => Promise<any>;

export interface EventSubscription {
  unsubscribe(): Promise<any>;
}

class DefaultSubscription implements EventSubscription {
  private readonly eventParser: EventParser;
  private isInterrupted = false;
  private subscriptionId?: number;

  constructor(
    private readonly program: anchor.Program,
    private readonly eventHandler: EventHandler,
  ) {
    this.eventParser = new EventParser(program.programId, program.coder);
  }

  async start(): Promise<EventSubscription> {
    this.periodicallyReconnect();
    return this;
  }

  async reconnectSubscriptions() {
    await this.unsubscribeFromLogsIfSubscribed();
    this.subscriptionId = this.program.provider.connection.onLogs(
      this.program.programId,
      async (logs) => {
        if (logs.err) {
          console.error(logs);
          return;
        }
        this.eventParser.parseLogs(logs.logs, (event) => {
          if (!this.isInterrupted) {
            switch (event.name) {
              case 'DialectCreatedEvent':
                this.eventHandler({
                  type: 'dialect-created',
                  dialect: event.data.dialect as PublicKey,
                  members: event.data.members as PublicKey[],
                });
                break;
              case 'DialectDeletedEvent':
                this.eventHandler({
                  type: 'dialect-deleted',
                  dialect: event.data.dialect as PublicKey,
                  members: event.data.members as PublicKey[],
                });
                break;
              case 'MessageSentEvent':
                this.eventHandler({
                  type: 'message-sent',
                  dialect: event.data.dialect as PublicKey,
                  sender: event.data.sender as PublicKey,
                });
                break;
              case 'UserSubscribedEvent':
                this.eventHandler({
                  type: 'user-subscribed',
                  metadata: event.data.metadata as PublicKey,
                  dialect: event.data.dialect as PublicKey,
                });
                break;
              case 'MetadataCreatedEvent':
                this.eventHandler({
                  type: 'metadata-created',
                  metadata: event.data.metadata as PublicKey,
                  user: event.data.user as PublicKey,
                });
                break;
              case 'MetadataDeletedEvent':
                this.eventHandler({
                  type: 'metadata-deleted',
                  metadata: event.data.metadata as PublicKey,
                  user: event.data.user as PublicKey,
                });
                break;
              default:
                console.log('Unsupported event type', event.name);
            }
          }
        });
      },
    );
  }

  unsubscribe(): Promise<void> {
    this.isInterrupted = true;
    return this.unsubscribeFromLogsIfSubscribed();
  }

  private async periodicallyReconnect() {
    while (!this.isInterrupted) {
      await this.reconnectSubscriptions();
      await sleep(1000 * 60);
    }
  }

  private unsubscribeFromLogsIfSubscribed() {
    return this.subscriptionId
      ? this.program.provider.connection.removeOnLogsListener(
          this.subscriptionId,
        )
      : Promise.resolve();
  }
}

export async function subscribeToEvents(
  program: anchor.Program,
  eventHandler: EventHandler,
): Promise<EventSubscription> {
  return new DefaultSubscription(program, eventHandler).start();
}