// 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.

/**  Decodes and detokenizes strings from binary or Base64 input. */
import { Buffer } from 'buffer';
import { Frame } from 'pigweedjs/pw_hdlc';
import { TokenDatabase } from './token_database';
import { PrintfDecoder } from './printf_decoder';

const MAX_RECURSIONS = 9;
const BASE64CHARS = '[A-Za-z0-9+/-_]';
const PATTERN = new RegExp(
  // Base64 tokenized strings start with the prefix character ($)
  '\\$' +
    // Tokenized strings contain 0 or more blocks of four Base64 chars.
    `(?:${BASE64CHARS}{4})*` +
    // The last block of 4 chars may have one or two padding chars (=).
    `(?:${BASE64CHARS}{3}=|${BASE64CHARS}{2}==)?`,
  'g',
);

interface TokenAndArgs {
  token: number;
  args: Uint8Array;
}

export class Detokenizer {
  private database: TokenDatabase;

  constructor(csvDatabase: string) {
    this.database = new TokenDatabase(csvDatabase);
  }

  /**
   * Detokenize frame data into actual string messages using the provided
   * token database.
   *
   * If the frame doesn't match any token from database, the frame will be
   * returned as string as-is.
   */
  detokenize(tokenizedFrame: Frame): string {
    return this.detokenizeUint8Array(tokenizedFrame.data);
  }

  /**
   * Detokenize uint8 into actual string messages using the provided
   * token database.
   *
   * If the data doesn't match any token from database, the data will be
   * returned as string as-is.
   */
  detokenizeUint8Array(data: Uint8Array): string {
    const { token, args } = this.decodeUint8Array(data);
    // Parse arguments if this is printf-style text.
    const format = this.database.get(token);
    if (format) {
      return new PrintfDecoder().decode(String(format), args);
    }

    return new TextDecoder().decode(data);
  }

  /**
   * Detokenize Base64-encoded frame data into actual string messages using the
   * provided token database.
   *
   * If the frame doesn't match any token from database, the frame will be
   * returned as string as-is.
   */
  detokenizeBase64(
    tokenizedFrame: Frame,
    maxRecursion: number = MAX_RECURSIONS,
  ): string {
    const base64String = new TextDecoder().decode(tokenizedFrame.data);
    return this.detokenizeBase64String(base64String, maxRecursion);
  }

  private detokenizeBase64String(
    base64String: string,
    recursions: number,
  ): string {
    return base64String.replace(PATTERN, (base64Substring) => {
      const { token, args } = this.decodeBase64TokenFrame(base64Substring);
      const format = this.database.get(token);
      // Parse arguments if this is printf-style text.
      if (format) {
        const decodedOriginal = new PrintfDecoder().decode(
          String(format),
          args,
        );
        // Detokenize nested Base64 tokens and their arguments.
        if (recursions > 0) {
          return this.detokenizeBase64String(decodedOriginal, recursions - 1);
        }
        return decodedOriginal;
      }
      return base64Substring;
    });
  }

  private decodeUint8Array(data: Uint8Array): TokenAndArgs {
    const token = new DataView(data.buffer, data.byteOffset, 4).getUint32(
      0,
      true,
    );
    const args = new Uint8Array(data.buffer.slice(data.byteOffset + 4));

    return { token, args };
  }

  private decodeBase64TokenFrame(base64Data: string): TokenAndArgs {
    // Remove the prefix '$' and convert from Base64.
    const prefixRemoved = base64Data.slice(1);
    const noBase64 = Buffer.from(prefixRemoved, 'base64').toString('binary');
    // Convert back to bytes and return token and arguments.
    const bytes = noBase64.split('').map((ch) => ch.charCodeAt(0));
    const uIntArray = new Uint8Array(bytes);
    const token = new DataView(
      uIntArray.buffer,
      uIntArray.byteOffset,
      4,
    ).getUint32(0, true);
    const args = new Uint8Array(bytes.slice(4));

    return { token, args };
  }
}
