/*
 * Copyright 2021 Lightbend Inc.
 *
 * 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
 *
 *     http://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.
 */

import { Cloudevent } from './cloudevent';
import { JwtClaims } from './jwt-claims';

type MetadataValue = string | Buffer;

// Using an interface for compatibility with legacy JS code
interface MetadataEntry {
  readonly key: string;
  readonly bytesValue: Buffer | undefined;
  readonly stringValue: string | undefined;
}

interface MetadataMap {
  [key: string]: string | Buffer | undefined;
}

class MetadataMapProxyHandler implements ProxyHandler<MetadataMap> {
  private metadata: Metadata;

  constructor(metadata: Metadata) {
    this.metadata = metadata;
  }

  ownKeys(target: MetadataMap): ArrayLike<string | symbol> {
    const keys = new Array<string>();
    for (const entry of this.metadata.entries) {
      keys.push(entry.key);
    }
    return keys;
  }

  deleteProperty(target: MetadataMap, key: string | symbol): boolean {
    if (typeof key === 'string') {
      const hasKey = this.metadata.has(key as string);
      if (hasKey) {
        this.metadata.delete(key);
      }
      return hasKey;
    }
    return false;
  }

  get(target: MetadataMap, key: string | symbol, receiver: any): any {
    if (typeof key === 'string') {
      const lowercaseKey = (key as string).toLowerCase();
      for (const entry of this.metadata.entries) {
        if (lowercaseKey === entry.key.toLowerCase()) {
          if (entry.stringValue) {
            return entry.stringValue;
          }
          if (entry.bytesValue) {
            return entry.bytesValue;
          }
        }
      }
    }
    return undefined;
  }

  has(target: MetadataMap, key: string | symbol): boolean {
    if (typeof key === 'string') {
      const lowercaseKey = (key as string).toLowerCase();
      for (const entry of this.metadata.entries) {
        if (lowercaseKey === entry.key.toLowerCase()) {
          return true;
        }
      }
    }
    return false;
  }

  set(
    target: MetadataMap,
    key: string | symbol,
    value: any,
    receiver: any,
  ): boolean {
    if (typeof key === 'string') {
      this.metadata.delete(key as string);
      this.metadata.set(key as string, value);
      return true;
    }
    return false;
  }

  getOwnPropertyDescriptor(
    target: MetadataMap,
    key: string | symbol,
  ): PropertyDescriptor | undefined {
    const superThis = this;
    if (typeof key === 'string') {
      const v = this.get(target, key as string, null);
      if (v !== undefined) {
        return new (class implements PropertyDescriptor {
          readonly value = v;
          readonly writable = true;
          readonly enumerable = true;
          readonly configurable = false;

          get(): any {
            return v;
          }

          set(v: any): void {
            superThis.set(target, key, v, null);
          }
        })();
      }
    }
    return undefined;
  }
}

/**
 * Akka Serverless metadata.
 *
 * Metadata is treated as case insensitive on lookup, and case sensitive on set. Multiple values per key are supported,
 * setting a value will add it to the current values for that key. You should delete first if you wish to replace a
 * value.
 *
 * Values can either be strings or byte buffers. If a non string or byte buffer value is set, it will be converted to
 * a string using toString.
 *
 * @param entries - the list of entries
 */
export class Metadata {
  readonly entries: MetadataEntry[];
  /**
   * The metadata expressed as an object/map.
   *
   * The map is backed by the this Metadata object - changes to this map will be reflected in this metadata object and
   * changes to this object will be reflected in the map.
   *
   * The map will return the first metadata entry that matches the key, case insensitive, when properties are looked up.
   * When setting properties, it will replace all entries that match the key, case insensitive.
   */
  readonly asMap: MetadataMap = new Proxy(
    new (class implements MetadataMap {
      [key: string]: string | Buffer | undefined;
    })(),
    new MetadataMapProxyHandler(this),
  );
  /**
   * The Cloudevent data from this Metadata.
   *
   * This object is backed by this Metadata, changes to the Cloudevent will be reflected in the Metadata.
   */
  readonly cloudevent: Cloudevent = new Cloudevent(this);

  /**
   * The JWT claims, if there was a validated bearer token with this request.
   */
  readonly jwtClaims: JwtClaims = new JwtClaims(this);

  constructor(entries: MetadataEntry[] = []) {
    this.entries = entries;
  }

  /**
   * If this metadata object is being returned as the metadata for an HTTP transcoded response, this will set the
   * HTTP status code for the response.
   *
   * This will have no impact on gRPC responses.
   *
   * @param code The status code to set.
   */
  setHttpStatusCode(code: number) {
    if (code < 100 || code >= 600) {
      throw new Error('Invalid HTTP status code: ' + code);
    }
    this.set('_akkasls-http-code', code.toString());
  }

  /**
   * @returns CloudEvent subject value
   * @deprecated Use cloudevent.subject instead.
   */
  getSubject(): MetadataValue | undefined {
    const subject = this.get('ce-subject');
    if (subject.length > 0) {
      return subject[0];
    } else {
      return undefined;
    }
  }

  /**
   * Create a new MetadataEntry.
   *
   * @param key - the key for the entry
   * @param value - the value for the entry
   * @returns a new MetadataEntry
   */
  private createMetadataEntry(key: string, value: any): MetadataEntry {
    if (typeof value === 'string') {
      return { key: key, stringValue: value, bytesValue: undefined };
    } else if (Buffer.isBuffer(value)) {
      return { key: key, bytesValue: value, stringValue: undefined };
    } else {
      return { key: key, stringValue: value.toString(), bytesValue: undefined };
    }
  }

  /**
   * Get the value from a metadata entry.
   *
   * @param entry - the metadata entry
   * @returns the value for the given entry
   */
  private getValue(entry: MetadataEntry): MetadataValue | undefined {
    if (entry.bytesValue !== undefined) {
      return entry.bytesValue;
    } else {
      return entry.stringValue;
    }
  }

  /**
   * Get all the values for the given key.
   *
   * The key is case insensitive.
   *
   * @param key - the key to get
   * @returns all the values, or an empty array if no values exist for the key
   */
  get(key: string): MetadataValue[] {
    const values: MetadataValue[] = [];
    this.entries.forEach((entry) => {
      if (key.toLowerCase() === entry.key.toLowerCase()) {
        const value = this.getValue(entry);
        if (value) {
          values.push(value);
        }
      }
    });
    return values;
  }

  /**
   * Set a given key value.
   *
   * This will append the key value to the metadata, it won't replace any existing values for existing keys.
   *
   * @param key - the key to set
   * @param value - the value to set
   * @returns this updated metadata
   */
  set(key: string, value: any): Metadata {
    this.entries.push(this.createMetadataEntry(key, value));
    return this;
  }

  /**
   * Delete all values with the given key.
   *
   * The key is case insensitive.
   *
   * @param key - the key to delete
   * @returns this updated metadata
   */
  delete(key: string) {
    let idx = 0;
    while (idx < this.entries.length) {
      const entry = this.entries[idx];
      if (key.toLowerCase() !== entry.key.toLowerCase()) {
        idx++;
      } else {
        this.entries.splice(idx, 1);
      }
    }
    return this;
  }

  /**
   * Whether there exists a metadata value for the given key.
   *
   * The key is case insensitive.
   *
   * @param key - the key to check
   * @returns whether values exist for the given key
   */
  has(key: string): boolean {
    for (const idx in this.entries) {
      const entry = this.entries[idx];
      if (key.toLowerCase() === entry.key.toLowerCase()) {
        return true;
      }
    }
    return false;
  }

  /**
   * Clear the metadata.
   *
   * @returns this updated metadata
   */
  clear() {
    this.entries.splice(0, this.entries.length);
    return this;
  }
}
