// Copyright 2022 The Pigweed Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy of
// the License at
//
//     https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations under
// the License.

/* eslint-env browser */

import {
  Channel,
  Client,
  decode,
  MethodStub,
  ServiceClient,
} from 'pigweedjs/pw_rpc';
import { Status } from 'pigweedjs/pw_status';
import {
  PacketType,
  RpcPacket,
} from 'pigweedjs/protos/pw_rpc/internal/packet_pb';
import { ProtoCollection } from 'pigweedjs/protos/collection';
import { Chunk } from 'pigweedjs/protos/pw_transfer/transfer_pb';

import { Manager } from './client';
import { ProgressStats } from './transfer';

const DEFAULT_TIMEOUT_S = 0.3;

describe('Transfer client', () => {
  const textEncoder = new TextEncoder();
  const textDecoder = new TextDecoder();
  let client: Client;
  let service: ServiceClient;
  let sentChunks: Chunk[];
  let packetsToSend: Uint8Array[][];

  beforeEach(() => {
    const lib = new ProtoCollection();
    const channels: Channel[] = [new Channel(1, handleRequest)];
    client = Client.fromProtoSet(channels, lib);
    service = client.channel(1)!.service('pw.transfer.Transfer')!;

    sentChunks = [];
    packetsToSend = [];
  });

  function handleRequest(data: Uint8Array): void {
    const packet = decode(data);
    if (packet.getType() !== PacketType.CLIENT_STREAM) {
      return;
    }

    const chunk = Chunk.deserializeBinary(packet.getPayload_asU8());
    sentChunks.push(chunk);

    if (packetsToSend.length > 0) {
      const responses = packetsToSend.shift()!;
      for (const response of responses) {
        client.processPacket(response);
      }
    }
  }

  function receivedData(): Uint8Array {
    let length = 0;
    sentChunks.forEach((chunk: Chunk) => {
      length += chunk.getData().length;
    });
    const data = new Uint8Array(length);
    let offset = 0;
    sentChunks.forEach((chunk: Chunk) => {
      data.set(chunk.getData() as Uint8Array, offset);
      offset += chunk.getData().length;
    });
    return data;
  }

  function enqueueServerError(method: MethodStub, error: Status): void {
    const packet = new RpcPacket();
    packet.setType(PacketType.SERVER_ERROR);
    packet.setChannelId(1);
    packet.setServiceId(service.id);
    packet.setMethodId(method.id);
    packet.setCallId(method.rpcs.nextCallId);
    packet.setStatus(error);
    packetsToSend.push([packet.serializeBinary()]);
  }

  function enqueueServerResponses(method: MethodStub, responses: Chunk[][]) {
    for (const responseGroup of responses) {
      const serializedGroup = [];
      for (const response of responseGroup) {
        const packet = new RpcPacket();
        packet.setType(PacketType.SERVER_STREAM);
        packet.setChannelId(1);
        packet.setServiceId(service.id);
        packet.setMethodId(method.id);
        packet.setCallId(method.rpcs.nextCallId);
        packet.setStatus(Status.OK);
        packet.setPayload(response.serializeBinary());
        serializedGroup.push(packet.serializeBinary());
      }
      packetsToSend.push(serializedGroup);
    }
  }

  function buildChunk(
    sessionId: number,
    offset: number,
    data: string,
    remainingBytes: number,
  ): Chunk {
    const chunk = new Chunk();
    chunk.setTransferId(sessionId);
    chunk.setOffset(offset);
    chunk.setData(textEncoder.encode(data));
    chunk.setRemainingBytes(remainingBytes);
    return chunk;
  }

  it('read transfer basic', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk1 = buildChunk(3, 0, 'abc', 0);
    enqueueServerResponses(service.method('Read')!, [[chunk1]]);

    const data = await manager.read(3);
    expect(textDecoder.decode(data)).toEqual('abc');
    expect(sentChunks).toHaveLength(2);
    expect(sentChunks[sentChunks.length - 1].hasStatus()).toBe(true);
    expect(sentChunks[sentChunks.length - 1].getStatus()).toEqual(Status.OK);
  });

  it('read transfer multichunk', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk1 = buildChunk(3, 0, 'abc', 3);
    const chunk2 = buildChunk(3, 3, 'def', 0);
    enqueueServerResponses(service.method('Read')!, [[chunk1, chunk2]]);

    const data = await manager.read(3);
    expect(data).toEqual(textEncoder.encode('abcdef'));
    expect(sentChunks).toHaveLength(2);
    expect(sentChunks[sentChunks.length - 1].hasStatus()).toBe(true);
    expect(sentChunks[sentChunks.length - 1].getStatus()).toEqual(Status.OK);
  });

  it('read transfer progress callback', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk1 = buildChunk(3, 0, 'abc', 3);
    const chunk2 = buildChunk(3, 3, 'def', 0);
    enqueueServerResponses(service.method('Read')!, [[chunk1, chunk2]]);

    const progress: Array<ProgressStats> = [];

    const data = await manager.read(3, (stats: ProgressStats) => {
      progress.push(stats);
    });
    expect(textDecoder.decode(data)).toEqual('abcdef');
    expect(sentChunks).toHaveLength(2);
    expect(sentChunks[sentChunks.length - 1].hasStatus()).toBe(true);
    expect(sentChunks[sentChunks.length - 1].getStatus()).toEqual(Status.OK);

    expect(progress).toEqual([
      new ProgressStats(3, 3, 6),
      new ProgressStats(6, 6, 6),
    ]);
  });

  it('read transfer retry bad offset', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk1 = buildChunk(3, 0, '123', 6);
    const chunk2 = buildChunk(3, 1, '456', 3); // Incorrect offset; expecting 3
    const chunk3 = buildChunk(3, 3, '456', 3);
    const chunk4 = buildChunk(3, 6, '789', 0);

    enqueueServerResponses(service.method('Read')!, [
      [chunk1, chunk2],
      [chunk3, chunk4],
    ]);

    const data = await manager.read(3);
    expect(data).toEqual(textEncoder.encode('123456789'));
    expect(sentChunks).toHaveLength(3);
    expect(sentChunks[sentChunks.length - 1].hasStatus()).toBe(true);
    expect(sentChunks[sentChunks.length - 1].getStatus()).toEqual(Status.OK);
  });

  it('read transfer retry timeout', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk = buildChunk(3, 0, 'xyz', 0);
    enqueueServerResponses(service.method('Read')!, [[], [chunk]]);

    const data = await manager.read(3);
    expect(textDecoder.decode(data)).toEqual('xyz');

    // Two transfer parameter requests should have been sent.
    expect(sentChunks).toHaveLength(3);
    expect(sentChunks[sentChunks.length - 1].hasStatus()).toBe(true);
    expect(sentChunks[sentChunks.length - 1].getStatus()).toEqual(Status.OK);
  });

  it('read transfer timeout', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    await manager
      .read(27)
      .then(() => {
        fail('Unexpected completed promise');
      })
      .catch((error) => {
        expect(error.id).toEqual(27);
        expect(Status[error.status]).toEqual(Status[Status.DEADLINE_EXCEEDED]);
        expect(sentChunks).toHaveLength(4);
      });
  });

  it('read transfer error', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk = new Chunk();
    chunk.setStatus(Status.NOT_FOUND);
    chunk.setTransferId(31);
    enqueueServerResponses(service.method('Read')!, [[chunk]]);

    await manager
      .read(31)
      .then(() => {
        fail('Unexpected completed promise');
      })
      .catch((error) => {
        expect(error.id).toEqual(31);
        expect(Status[error.status]).toEqual(Status[Status.NOT_FOUND]);
      });
  });

  it('read transfer server error', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    enqueueServerError(service.method('Read')!, Status.NOT_FOUND);
    await manager
      .read(31)
      .then((data) => {
        fail('Unexpected completed promise');
      })
      .catch((error) => {
        expect(error.id).toEqual(31);
        expect(Status[error.status]).toEqual(Status[Status.INTERNAL]);
      });
  });

  it('write transfer basic', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk = new Chunk();
    chunk.setTransferId(4);
    chunk.setOffset(0);
    chunk.setPendingBytes(32);
    chunk.setMaxChunkSizeBytes(8);

    const completeChunk = new Chunk();
    completeChunk.setTransferId(4);
    completeChunk.setStatus(Status.OK);

    enqueueServerResponses(service.method('Write')!, [
      [chunk],
      [completeChunk],
    ]);

    await manager.write(4, textEncoder.encode('hello'));
    expect(sentChunks).toHaveLength(2);
    expect(receivedData()).toEqual(textEncoder.encode('hello'));
  });

  it('write transfer max chunk size', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk = new Chunk();
    chunk.setTransferId(4);
    chunk.setOffset(0);
    chunk.setPendingBytes(32);
    chunk.setMaxChunkSizeBytes(8);

    const completeChunk = new Chunk();
    completeChunk.setTransferId(4);
    completeChunk.setStatus(Status.OK);

    enqueueServerResponses(service.method('Write')!, [
      [chunk],
      [completeChunk],
    ]);

    await manager.write(4, textEncoder.encode('hello world'));
    expect(sentChunks).toHaveLength(3);
    expect(receivedData()).toEqual(textEncoder.encode('hello world'));
    expect(sentChunks[1].getData()).toEqual(textEncoder.encode('hello wo'));
    expect(sentChunks[2].getData()).toEqual(textEncoder.encode('rld'));
  });

  it('write transfer multiple parameters', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk = new Chunk();
    chunk.setTransferId(4);
    chunk.setOffset(0);
    chunk.setPendingBytes(8);
    chunk.setMaxChunkSizeBytes(8);

    const chunk2 = new Chunk();
    chunk2.setTransferId(4);
    chunk2.setOffset(8);
    chunk2.setPendingBytes(8);
    chunk2.setMaxChunkSizeBytes(8);

    const completeChunk = new Chunk();
    completeChunk.setTransferId(4);
    completeChunk.setStatus(Status.OK);

    enqueueServerResponses(service.method('Write')!, [
      [chunk],
      [chunk2],
      [completeChunk],
    ]);

    await manager.write(4, textEncoder.encode('data to write'));
    expect(sentChunks).toHaveLength(3);
    expect(receivedData()).toEqual(textEncoder.encode('data to write'));
    expect(sentChunks[1].getData()).toEqual(textEncoder.encode('data to '));
    expect(sentChunks[2].getData()).toEqual(textEncoder.encode('write'));
  });

  it('write transfer parameters update', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk = new Chunk();
    chunk.setTransferId(4);
    chunk.setOffset(0);
    chunk.setPendingBytes(8);
    chunk.setMaxChunkSizeBytes(4);
    chunk.setType(Chunk.Type.PARAMETERS_RETRANSMIT);
    chunk.setWindowEndOffset(8);

    const chunk2 = new Chunk();
    chunk2.setTransferId(4);
    chunk2.setOffset(4);
    chunk2.setPendingBytes(8);
    chunk2.setType(Chunk.Type.PARAMETERS_CONTINUE);
    chunk2.setWindowEndOffset(12);

    const chunk3 = new Chunk();
    chunk3.setTransferId(4);
    chunk3.setOffset(8);
    chunk3.setPendingBytes(8);
    chunk3.setType(Chunk.Type.PARAMETERS_CONTINUE);
    chunk3.setWindowEndOffset(16);

    const chunk4 = new Chunk();
    chunk4.setTransferId(4);
    chunk4.setOffset(12);
    chunk4.setPendingBytes(8);
    chunk4.setType(Chunk.Type.PARAMETERS_CONTINUE);
    chunk4.setWindowEndOffset(20);

    const chunk5 = new Chunk();
    chunk5.setTransferId(4);
    chunk5.setOffset(16);
    chunk5.setPendingBytes(8);
    chunk5.setType(Chunk.Type.PARAMETERS_CONTINUE);
    chunk5.setWindowEndOffset(24);

    const chunk6 = new Chunk();
    chunk6.setTransferId(4);
    chunk6.setOffset(20);
    chunk6.setPendingBytes(8);
    chunk6.setType(Chunk.Type.PARAMETERS_CONTINUE);
    chunk6.setWindowEndOffset(28);

    const completeChunk = new Chunk();
    completeChunk.setTransferId(4);
    completeChunk.setStatus(Status.OK);

    enqueueServerResponses(service.method('Write')!, [
      [chunk],
      [chunk2],
      [chunk3],
      [chunk4],
      [chunk5],
      [chunk6],
      [completeChunk],
    ]);

    await manager.write(4, textEncoder.encode('hello this is a message'));
    expect(receivedData()).toEqual(
      textEncoder.encode('hello this is a message'),
    );
    expect(sentChunks[1].getData()).toEqual(textEncoder.encode('hell'));
    expect(sentChunks[2].getData()).toEqual(textEncoder.encode('o th'));
    expect(sentChunks[3].getData()).toEqual(textEncoder.encode('is i'));
    expect(sentChunks[4].getData()).toEqual(textEncoder.encode('s a '));
    expect(sentChunks[5].getData()).toEqual(textEncoder.encode('mess'));
    expect(sentChunks[6].getData()).toEqual(textEncoder.encode('age'));
  });

  it('write transfer progress callback', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk = new Chunk();
    chunk.setTransferId(4);
    chunk.setOffset(0);
    chunk.setPendingBytes(8);
    chunk.setMaxChunkSizeBytes(8);

    const chunk2 = new Chunk();
    chunk2.setTransferId(4);
    chunk2.setOffset(8);
    chunk2.setPendingBytes(8);
    chunk2.setMaxChunkSizeBytes(8);

    const completeChunk = new Chunk();
    completeChunk.setTransferId(4);
    completeChunk.setStatus(Status.OK);

    enqueueServerResponses(service.method('Write')!, [
      [chunk],
      [chunk2],
      [completeChunk],
    ]);

    const progress: Array<ProgressStats> = [];
    await manager.write(
      4,
      textEncoder.encode('data to write'),
      (stats: ProgressStats) => {
        progress.push(stats);
      },
    );
    expect(sentChunks).toHaveLength(3);
    expect(receivedData()).toEqual(textEncoder.encode('data to write'));
    expect(sentChunks[1].getData()).toEqual(textEncoder.encode('data to '));
    expect(sentChunks[2].getData()).toEqual(textEncoder.encode('write'));

    console.log(progress);
    expect(progress).toEqual([
      new ProgressStats(8, 0, 13),
      new ProgressStats(13, 8, 13),
      new ProgressStats(13, 13, 13),
    ]);
  });

  it('write transfer rewind', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk1 = new Chunk();
    chunk1.setTransferId(4);
    chunk1.setOffset(0);
    chunk1.setPendingBytes(8);
    chunk1.setMaxChunkSizeBytes(8);

    const chunk2 = new Chunk();
    chunk2.setTransferId(4);
    chunk2.setOffset(8);
    chunk2.setPendingBytes(8);
    chunk2.setMaxChunkSizeBytes(8);

    const chunk3 = new Chunk();
    chunk3.setTransferId(4);
    chunk3.setOffset(4); // Rewind
    chunk3.setPendingBytes(8);
    chunk3.setMaxChunkSizeBytes(8);

    const chunk4 = new Chunk();
    chunk4.setTransferId(4);
    chunk4.setOffset(12); // Rewind
    chunk4.setPendingBytes(16);
    chunk4.setMaxChunkSizeBytes(16);

    const completeChunk = new Chunk();
    completeChunk.setTransferId(4);
    completeChunk.setStatus(Status.OK);

    enqueueServerResponses(service.method('Write')!, [
      [chunk1],
      [chunk2],
      [chunk3],
      [chunk4],
      [completeChunk],
    ]);

    await manager.write(4, textEncoder.encode('pigweed data transfer'));
    expect(sentChunks).toHaveLength(5);
    expect(sentChunks[1].getData()).toEqual(textEncoder.encode('pigweed '));
    expect(sentChunks[2].getData()).toEqual(textEncoder.encode('data tra'));
    expect(sentChunks[3].getData()).toEqual(textEncoder.encode('eed data'));
    expect(sentChunks[4].getData()).toEqual(textEncoder.encode(' transfer'));
  });

  it('write transfer bad offset', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk1 = new Chunk();
    chunk1.setTransferId(4);
    chunk1.setOffset(0);
    chunk1.setPendingBytes(8);
    chunk1.setMaxChunkSizeBytes(8);

    const chunk2 = new Chunk();
    chunk2.setTransferId(4);
    chunk2.setOffset(100); // larger offset than data
    chunk2.setPendingBytes(8);
    chunk2.setMaxChunkSizeBytes(8);

    const completeChunk = new Chunk();
    completeChunk.setTransferId(4);
    completeChunk.setStatus(Status.OK);

    enqueueServerResponses(service.method('Write')!, [
      [chunk1],
      [chunk2],
      [completeChunk],
    ]);

    await manager
      .write(4, textEncoder.encode('small data'))
      .then(() => {
        fail('Unexpected succesful promise');
      })
      .catch((error) => {
        expect(error.id).toEqual(4);
        expect(Status[error.status]).toEqual(Status[Status.OUT_OF_RANGE]);
      });
  });

  it('write transfer error', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk = new Chunk();
    chunk.setTransferId(21);
    chunk.setStatus(Status.UNAVAILABLE);

    enqueueServerResponses(service.method('Write')!, [[chunk]]);

    await manager
      .write(21, textEncoder.encode('no write'))
      .then(() => {
        fail('Unexpected succesful promise');
      })
      .catch((error) => {
        expect(error.id).toEqual(21);
        expect(Status[error.status]).toEqual(Status[Status.UNAVAILABLE]);
      });
  });

  it('write transfer server error', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk = new Chunk();
    chunk.setTransferId(21);
    chunk.setStatus(Status.NOT_FOUND);

    enqueueServerError(service.method('Write')!, Status.NOT_FOUND);

    await manager
      .write(21, textEncoder.encode('server error'))
      .then(() => {
        fail('Unexpected succesful promise');
      })
      .catch((error) => {
        expect(error.id).toEqual(21);
        expect(Status[error.status]).toEqual(Status[Status.INTERNAL]);
      });
  });

  it('write transfer timeout after initial chunk', async () => {
    const manager = new Manager(service, 0.001, 4, 2);

    await manager
      .write(22, textEncoder.encode('no server response!'))
      .then(() => {
        fail('unexpected succesful write');
      })
      .catch((error) => {
        expect(sentChunks).toHaveLength(3); // Initial chunk + two retries.
        expect(error.id).toEqual(22);
        expect(Status[error.status]).toEqual(Status[Status.DEADLINE_EXCEEDED]);
      });
  });

  it('write transfer timeout after intermediate chunk', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S, 4, 2);

    const chunk = new Chunk();
    chunk.setTransferId(22);
    chunk.setPendingBytes(10);
    chunk.setMaxChunkSizeBytes(5);

    enqueueServerResponses(service.method('Write')!, [[chunk]]);

    await manager
      .write(22, textEncoder.encode('0123456789'))
      .then(() => {
        fail('unexpected succesful write');
      })
      .catch((error) => {
        const expectedChunk1 = new Chunk();
        expectedChunk1.setTransferId(22);
        expectedChunk1.setResourceId(22);
        expectedChunk1.setType(Chunk.Type.START);
        const expectedChunk2 = new Chunk();
        expectedChunk2.setTransferId(22);
        expectedChunk2.setData(textEncoder.encode('01234'));
        expectedChunk2.setType(Chunk.Type.DATA);
        const lastChunk = new Chunk();
        lastChunk.setTransferId(22);
        lastChunk.setData(textEncoder.encode('56789'));
        lastChunk.setOffset(5);
        lastChunk.setRemainingBytes(0);
        lastChunk.setType(Chunk.Type.DATA);

        const expectedChunks = [
          expectedChunk1,
          expectedChunk2,
          lastChunk,
          lastChunk, // retry 1
          lastChunk, // retry 2
        ];

        expect(sentChunks).toEqual(expectedChunks);

        expect(error.id).toEqual(22);
        expect(Status[error.status]).toEqual(Status[Status.DEADLINE_EXCEEDED]);
      });
  });

  it('write zero pending bytes is internal error', async () => {
    const manager = new Manager(service, DEFAULT_TIMEOUT_S);

    const chunk = new Chunk();
    chunk.setTransferId(23);
    chunk.setPendingBytes(0);

    enqueueServerResponses(service.method('Write')!, [[chunk]]);

    await manager
      .write(23, textEncoder.encode('no write'))
      .then(() => {
        fail('Unexpected succesful promise');
      })
      .catch((error) => {
        expect(error.id).toEqual(23);
        expect(Status[error.status]).toEqual(Status[Status.INTERNAL]);
      });
  });
});
