import {
  GenericMessage,
  MessagesGetReply,
  PrivateKeySigner,
  RecordsWriteMessage,
  RecordsWriteOptions,
  Signer,
  UnionMessageReply,
} from '@dwn-protocol/id';

import { Convert } from '../common/index.js';
import { Jose } from '../crypto/index.js';
import { DidResolver } from '../dids/index.js';
import { Readable } from 'readable-stream';
import * as didUtils from '../dids/utils.js';

import {
  Cid,
  Dwn,
  Message,
  EventsGet,
  DataStream,
  RecordsRead,
  MessagesGet,
  RecordsWrite,
  RecordsQuery,
  DwnMethodName,
  RecordsDelete,
  ProtocolsQuery,
  DwnInterfaceName,
  ProtocolsConfigure,
  EventLogLevel,
  DataStoreLevel,
  MessageStoreLevel,
} from '@dwn-protocol/id';

import type { DwnRpcRequest } from './rpc-client.js';
import type { DwnResponse,ProcessDwnRequest, SendDwnRequest, IDManagedAgent } from './types/agent.js';

import { isManagedKeyPair } from './utils.js';
import { blobToIsomorphicNodeReadable, webReadableToIsomorphicNodeReadable } from './utils.js';

export type GeneralJws = {
  payload: string
  signatures: SignatureEntry[]
};

export type SignatureEntry = {
  protected: string
  signature: string
};

export type RecordsWriteAuthorizationPayload = {
  recordId: string;
  contextId?: string;
  descriptorCid: string;
  attestationCid?: string;
  encryptionCid?: string;
};

type DwnMessage = {
  message: any;
  data?: Blob;
}

const dwnMessageCreators = {
  [DwnInterfaceName.Events + DwnMethodName.Get]          : EventsGet,
  [DwnInterfaceName.Messages + DwnMethodName.Get]        : MessagesGet,
  [DwnInterfaceName.Records + DwnMethodName.Read]        : RecordsRead,
  [DwnInterfaceName.Records + DwnMethodName.Query]       : RecordsQuery,
  [DwnInterfaceName.Records + DwnMethodName.Write]       : RecordsWrite,
  [DwnInterfaceName.Records + DwnMethodName.Delete]      : RecordsDelete,
  [DwnInterfaceName.Protocols + DwnMethodName.Query]     : ProtocolsQuery,
  [DwnInterfaceName.Protocols + DwnMethodName.Configure] : ProtocolsConfigure,
};

export type DwnManagerOptions = {
  agent?: IDManagedAgent;
  dwn: Dwn;
}

export type DwnManagerCreateOptions = {
  agent?: IDManagedAgent;
  dataPath?: string;
  didResolver?: DidResolver;
  dwn?: Dwn;
}

export class DwnManager {
  /**
   * Holds the instance of a `IDManagedAgent` that represents the current
   * execution context for the `KeyManager`. This agent is utilized
   * to interact with other agent components. It's vital
   * to ensure this instance is set to correctly contextualize
   * operations within the broader agent framework.
   */
  private _agent?: IDManagedAgent;
  private _dwn: Dwn;

  constructor(options: DwnManagerOptions) {
    this._agent = options.agent;
    this._dwn = options.dwn;
  }

  /**
   * Constructs a Signer for the connected did.
   *
   * @param author - The DID.
   * @returns A promise that resolves to the result.
   */
  async getSigner(author: string): Promise<Signer> {
    const signingKeyId = await this.getAuthorSigningKeyId({ did: author });
    const parsedDid = didUtils.parseDid({ didUrl: signingKeyId });
    if (!parsedDid) throw new Error(`DidIonMethod: Unable to parse DID: ${signingKeyId}`);
    const normalizedDid = parsedDid.did.split(':', 3).join(':');
    const normalizedSigningKeyId = `${normalizedDid}#${parsedDid.fragment}`;

    const signingKey = await this.agent.keyManager.getKey({ keyRef: normalizedSigningKeyId });
    if (!isManagedKeyPair(signingKey)) {
      throw new Error(`DwnManager: Signing key not found for author: '${author}'`);
    }

    const { alg } = Jose.webCryptoToJose(signingKey.privateKey.algorithm);
    if (alg === undefined) {
      throw Error(`No algorithm provided to sign with key ID ${signingKeyId}`);
    }

    return {
      keyId     : signingKeyId,
      algorithm : alg,
      sign      : async (content: Uint8Array): Promise<Uint8Array> => {
        return await this.agent.keyManager.sign({
          algorithm : signingKey.privateKey.algorithm,
          data      : content,
          keyRef    : normalizedSigningKeyId
        });
      },
    };
  }

  /**
   * Constructs a Private Key Signer for a did.
   *
   * @param author - The DID Object.
   * @returns A promise that resolves to the result.
   */
  async getPrivateKeySigner(author: any) {
    const signingKeyId = await this.agent.didManager.getDefaultSigningKey({ did: author.did });
    const signingKeyPair = author.keySet.verificationMethodKeys[0];
    // const signingKeyPair = ionDid.keySet.verificationMethodKeys.find(keyPair => keyPair.publicKeyJwk.kid === "#dwn-sig");
    const signingPrivateKeyJwk = signingKeyPair.privateKeyJwk;
    return [new PrivateKeySigner({
      privateJwk : signingPrivateKeyJwk,
      algorithm  : signingPrivateKeyJwk.alg,
      keyId      : signingKeyId,
    })];
  }

  /**
   * Retrieves the `IDManagedAgent` execution context.
   * If the `agent` instance proprety is undefined, it will throw an error.
   *
   * @returns The `IDManagedAgent` instance that represents the current execution
   * context.
   *
   * @throws Will throw an error if the `agent` instance property is undefined.
   */
  get agent(): IDManagedAgent {
    if (this._agent === undefined) {
      throw new Error('DidManager: Unable to determine agent execution context.');
    }

    return this._agent;
  }

  set agent(agent: IDManagedAgent) {
    this._agent = agent;
  }

  get dwn(): Dwn {
    return this._dwn;
  }

  public static async create(options?: DwnManagerCreateOptions) {
    let { agent, dataPath, didResolver, dwn } = options ?? { };

    dataPath ??= 'data/AGENT';

    if (dwn === undefined) {
      const dataStore = new DataStoreLevel({
        blockstoreLocation: `${dataPath}/DWN_DATASTORE`
      });
      const eventLog = new EventLogLevel({
        location: `${dataPath}/DWN_EVENTLOG`
      });
      const messageStore = new MessageStoreLevel(({
        blockstoreLocation : `${dataPath}/DWN_MESSAGESTORE`,
        indexLocation      : `${dataPath}/DWN_MESSAGEINDEX`
      }));

      dwn = await Dwn.create({
        dataStore,
        //@ts-ignore
        didResolver,
        eventLog,
        messageStore,
      });
    }

    return new DwnManager({ agent, dwn });
  }

  public async processRequest(request: ProcessDwnRequest): Promise<DwnResponse> {
    const { message, dataStream } = await this.constructDwnMessage({ request });

    let reply: UnionMessageReply;
    if (request.store !== false) {
      reply = await this._dwn.processMessage(request.target, message, dataStream);
    } else {
      reply = { status: { code: 202, detail: 'Accepted' }};
    }

    return {
      reply,
      message    : message,
      messageCid : await Message.getCid(message)
    };
  }

  public async sendRequest(request: SendDwnRequest): Promise<DwnResponse> {
    const dwnRpcRequest: Partial<DwnRpcRequest> = { targetDid: request.target };
    let messageData: Blob | Readable | ReadableStream | undefined;

    if ('messageCid' in request) {
      const { message, data } =  await this.getDwnMessage({
        author      : request.author,
        messageCid  : request.messageCid,
        messageType : request.messageType
      });
      dwnRpcRequest.message = message;
      messageData = data;

    } else {
      const { message } = await this.constructDwnMessage({ request });
      dwnRpcRequest.message = message;
      messageData = request.dataStream;
    }

    if (messageData) {
      dwnRpcRequest.data = messageData;
    }

    const { didDocument, didResolutionMetadata } = await this.agent.didResolver.resolve(request.target);
    if (!didDocument) {
      const errorCode = `${didResolutionMetadata?.error}: ` ?? '';
      const defaultMessage = `Unable to resolve target DID: ${request.target}`;
      const errorMessage = didResolutionMetadata?.errorMessage ?? defaultMessage;
      throw new Error(`DwnManager: ${errorCode}${errorMessage}`);
    }

    const [ service ] = didUtils.getServices({ didDocument, id: '#dwn' });
    if (!service) {
      throw new Error(`DwnManager: DID Document of '${request.target}' has no service endpoints with ID '#dwn'`);
    }

    if (!didUtils.isDwnServiceEndpoint(service.serviceEndpoint)) {
      throw new Error(`DwnManager: Malformed '#dwn' service endpoint. Expected array of node addresses.`);
    }
    const dwnEndpointUrls = service.serviceEndpoint.nodes;

    let dwnReply;
    let errorMessages = [];

    // try sending to author's publicly addressable dwn's until first request succeeds.
    for (let dwnUrl of dwnEndpointUrls) {
      dwnRpcRequest.dwnUrl = dwnUrl;

      try {
        dwnReply = await this.agent.rpcClient.sendDwnRequest(dwnRpcRequest as DwnRpcRequest);
        break;
      } catch(error: unknown) {
        const message = (error instanceof Error) ? error.message : 'Unknown error';
        errorMessages.push({ url: dwnUrl, message });
      }
    }

    if (!dwnReply) {
      throw new Error(JSON.stringify(errorMessages));
    }

    return {
      message    : dwnRpcRequest.message,
      messageCid : await Message.getCid(dwnRpcRequest.message),
      reply      : dwnReply,
    };
  }

  private async constructDwnMessage(options: {
    request: ProcessDwnRequest
  }) {
    const { request } = options;

    let readableStream: Readable | undefined;

    if (request.messageType === 'RecordsWrite') {
      const messageOptions = request.messageOptions as RecordsWriteOptions;

      if (request.dataStream && !messageOptions.data) {
        const { dataStream } = request;
        let isomorphicNodeReadable: Readable;

        if (dataStream instanceof Blob) {
          isomorphicNodeReadable = blobToIsomorphicNodeReadable(dataStream);
          readableStream = blobToIsomorphicNodeReadable(dataStream);

        } else if (dataStream instanceof ReadableStream) {
          const [ forCid, forProcessMessage ] = dataStream.tee();
          isomorphicNodeReadable = webReadableToIsomorphicNodeReadable(forCid);
          readableStream = webReadableToIsomorphicNodeReadable(forProcessMessage);
        }

        // @ts-ignore
        messageOptions.dataCid = await Cid.computeDagPbCidFromStream(isomorphicNodeReadable);
        // @ts-ignore
        messageOptions.dataSize ??= isomorphicNodeReadable['bytesRead'];
      }
    }

    const dwnSigner = await this.constructDwnSigner(request.author);

    const messageCreator = dwnMessageCreators[request.messageType];
    const dwnMessage = await messageCreator.create({
      ...<any>request.messageOptions,
      signer: dwnSigner
    });

    // return { message: dwnMessage.toJSON(), dataStream: readableStream };
    return { message: dwnMessage.message, dataStream: readableStream };
  }

  private async getAuthorSigningKeyId(options: {
    did: string
  }): Promise<string> {
    const { did } = options;

    // Get the method-specific default signing key.
    const signingKeyId = await this.agent.didManager.getDefaultSigningKey({ did });

    if (!signingKeyId) {
      throw new Error (`DwnManager: Unable to determine signing key for author: '${did}'`);
    }

    return signingKeyId;
  }

  private async constructDwnSigner(author: string): Promise<Signer> {
    const signingKeyId = await this.getAuthorSigningKeyId({ did: author });

    /**
     * DID keys stored in KeyManager use the canonicalId as an alias, so
     * normalize the signing key ID before attempting to retrieve the key.
     */
    const parsedDid = didUtils.parseDid({ didUrl: signingKeyId });
    if (!parsedDid) throw new Error(`DidIonMethod: Unable to parse DID: ${signingKeyId}`);
    const normalizedDid = parsedDid.did.split(':', 3).join(':');
    const normalizedSigningKeyId = `${normalizedDid}#${parsedDid.fragment}`;

    const signingKey = await this.agent.keyManager.getKey({ keyRef: normalizedSigningKeyId });
    if (!isManagedKeyPair(signingKey)) {
      throw new Error(`DwnManager: Signing key not found for author: '${author}'`);
    }

    const { alg } = Jose.webCryptoToJose(signingKey.privateKey.algorithm);
    if (alg === undefined) {
      throw Error(`No algorithm provided to sign with key ID ${signingKeyId}`);
    }

    return {
      keyId     : signingKeyId,
      algorithm : alg,
      sign      : async (content: Uint8Array): Promise<Uint8Array> => {
        return await this.agent.keyManager.sign({
          algorithm : signingKey.privateKey.algorithm,
          data      : content,
          keyRef    : normalizedSigningKeyId
        });
      }
    };
  }

  private async getDwnMessage(options: {
    author: string,
    messageType: string,
    messageCid: string
  }): Promise<DwnMessage> {
    const { author, messageType, messageCid } = options;

    const dwnSigner = await this.constructDwnSigner(author);

    const messagesGet = await MessagesGet.create({
      messageCids : [messageCid],
      signer      : dwnSigner
    });

    const result: MessagesGetReply = await this._dwn.processMessage(author, messagesGet.message);

    if (!(result.messages && result.messages.length === 1)) {
      throw new Error('TODO: message not found');
    }

    const [ messageEntry ] = result.messages;

    let { message } = messageEntry;
    if (!message) {
      throw new Error('TODO: message not found');
    }

    let dwnMessage: DwnMessage = { message };

    /** If the message is a RecordsWrite, either data will be present, OR
     * we have to fetch it using a RecordsRead. */
    if (messageType === 'RecordsWrite') {
      const { encodedData } = messageEntry;
      const writeMessage = message as RecordsWriteMessage;

      if (encodedData) {
        const dataBytes = Convert.base64Url(encodedData).toUint8Array();
        dwnMessage.data = new Blob([dataBytes]);
      } else {
        const recordsRead = await RecordsRead.create({
          filter: {
            recordId: writeMessage.recordId
          },
          signer: dwnSigner
        });

        const reply = await this._dwn.processMessage(author, recordsRead.message);

        if (reply.status.code >= 400) {
          const { status: { code, detail } } = reply;
          throw new Error(`(${code}) Failed to read data associated with record ${writeMessage.recordId}. ${detail}}`);
        } else if (reply.record) {
          const dataBytes = await DataStream.toBytes(reply.record.data);
          dwnMessage.data = new Blob([dataBytes]);
        }
      }
    }

    return dwnMessage;
  }

  /**
   * ADDED TO GET SYNC WORKING
   * - createMessage()
   * - processMessage()
   * - writePrunedRecord()
   */

  public async createMessage(options: {
    author: string,
    messageOptions: unknown,
    messageType: string
  }): Promise<EventsGet | MessagesGet | RecordsRead | RecordsQuery | RecordsWrite | RecordsDelete | ProtocolsQuery | ProtocolsConfigure> {
    const { author, messageOptions, messageType } = options;

    const dwnSigner = await this.constructDwnSigner(author);

    const messageCreator = dwnMessageCreators[messageType];

    const dwnMessage = await messageCreator.create({
      ...<any>messageOptions,
      signer: dwnSigner
    });

    return dwnMessage;
  }

  /**
   * Writes a pruned initial `RecordsWrite` to a DWN without needing to supply associated data.
   * Note: This method should ONLY be used by a {@link SyncManager} implementation.
   *
   * @param options.targetDid - DID of the DWN tenant to write the pruned RecordsWrite to.
   * @returns DWN reply containing the status of processing request.
   */
  public async writePrunedRecord(options: {
    targetDid: string,
    message: RecordsWriteMessage
  }): Promise<GenericMessageReply> {
    const { targetDid, message } = options;

    return await this._dwn.synchronizePrunedInitialRecordsWrite(targetDid, message);
  }

  public async processMessage(options: {
    targetDid: string,
    message: GenericMessage,
    dataStream?: Readable
  }): Promise<UnionMessageReply> {
    const { dataStream, message, targetDid } = options;

    return await this._dwn.processMessage(targetDid, message, dataStream);
  }
}

type GenericMessageReply = {
  status: Status;
};

type Status = {
  code: number
  detail: string
};