import { CipherType } from "@bitwarden/common/vault/enums";
import { FieldType } from "@bitwarden/common/vault/enums/field-type.enum";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { import_ssh_key, SshKeyView } from "@bitwarden/sdk-internal";

import { ImportResult } from "../../models";
import { ImportRecordError, ImportRecordErrorReason } from "../../models/import-record-error";
import { BaseImporter } from "../base-importer";

import { Vault, VaultField, VaultItem, VaultRecordError, VaultRecordErrorReason } from "./access";

type Reference = {
  id: string;
  type: string;
};

// The Keeper direct import produces a plain ImportResult for the generic import pipeline plus a
// Keeper-specific list of per-record errors that the Keeper UI resolves before handing the result off.
export type KeeperDirectImportResult = {
  result: ImportResult;
  errors: ImportRecordError[];
};

// Keeper "file" and "photo" records are attachment wrappers whose bytes are only available through a
// separate download flow this importer cannot use. Skip them and report them as unsupported.
const UNSUPPORTED_RECORD_TYPES = new Set(["file", "photo"]);

export class KeeperDirectImporter extends BaseImporter {
  private references = new Map<string, Reference[]>();
  private idToCipher = new Map<string, CipherView>();

  convertVaultToImportResult(vault: Vault): KeeperDirectImportResult {
    const result = new ImportResult();
    const errors: ImportRecordError[] = [];

    this.references.clear();
    this.idToCipher.clear();

    const items = vault.getItems();
    this.parseRecords(items, result, errors);
    this.resolveReferences();

    this.mapVaultErrors(vault.getErrors(), errors);

    if (this.organization) {
      this.moveFoldersToCollections(result);
    }

    result.success = true;
    return { result, errors };
  }

  private mapVaultErrors(vaultErrors: VaultRecordError[], errors: ImportRecordError[]): void {
    for (const error of vaultErrors) {
      // The record/folder name was never decrypted, so the UID is the only identifier we have.
      errors.push(new ImportRecordError(error.id, mapVaultErrorReason(error.reason)));
    }
  }

  private parseRecords(
    items: VaultItem[],
    result: ImportResult,
    errors: ImportRecordError[],
  ): void {
    for (const item of items) {
      if (UNSUPPORTED_RECORD_TYPES.has(item.type)) {
        errors.push(
          new ImportRecordError(item.id, ImportRecordErrorReason.UnsupportedType, item.type),
        );
        continue;
      }

      try {
        this.parseRecord(item, result);
      } catch {
        errors.push(new ImportRecordError(item.id, ImportRecordErrorReason.Error, item.type));
      }
    }
  }

  private parseRecord(item: VaultItem, result: ImportResult): void {
    const cipher = this.initLoginCipher();
    cipher.name = this.getValueOrDefault(item.title);
    cipher.notes = this.getValueOrDefault(item.notes);

    // Track consumed fields by index so they aren't processed again
    const consumedFieldIndices = new Set<number>();

    // Handle special record types
    switch (item.type) {
      case "bankCard":
        this.importBankCard(item, cipher, consumedFieldIndices);
        break;
      case "driverLicense":
        this.importIdentity(item, cipher, consumedFieldIndices, (identity, value) => {
          identity.licenseNumber = value;
        });
        break;
      case "ssnCard":
        this.importIdentity(item, cipher, consumedFieldIndices, (identity, value) => {
          identity.ssn = value;
        });
        break;
      case "passport":
        this.importIdentity(item, cipher, consumedFieldIndices, (identity, value) => {
          identity.passportNumber = value;
        });
        break;
      case "sshKeys":
        // In Bitwarden the ssh key is supposed to be valid.
        // So we only set the type if we can actually import the key.
        if (!this.importSshKey(item, cipher, consumedFieldIndices)) {
          // Otherwise, fall back to a secure note.
          cipher.type = CipherType.SecureNote;
          // Preserve the passphrase and consume the password field so it isn't also imported as a
          // redundant "Password" custom field. The key pair, host, username and any other fields
          // flow through the standard field processing pipeline as custom fields.
          const passwordIndex = item.fields.findIndex((f) => f.type === "password");
          if (passwordIndex !== -1) {
            const passphrase = this.getFirstFieldValue(item, "password");
            this.addField(cipher, "Passphrase", passphrase, FieldType.Hidden);
            consumedFieldIndices.add(passwordIndex);
          }
        }
        break;
    }

    this.importFields(item, cipher, consumedFieldIndices);
    this.collectReferences(item);

    this.convertToNoteIfNeeded(cipher);
    this.cleanupCipher(cipher);

    // Commit. Everything above can throw; everything below only mutates state and must not.
    // processFolder pushes a folder relationship indexed on result.ciphers.length, so it must run
    // immediately before the cipher is pushed to keep the index aligned.
    for (const path of item.folders) {
      this.processFolder(result, path);
    }

    result.ciphers.push(cipher);

    // This is needed for resolving references later
    if (item.id) {
      this.idToCipher.set(item.id, cipher);
    }
  }

  private getFirstFieldValue(item: VaultItem, fieldType: string): string | undefined {
    const field = item.fields.find((f) => f.type === fieldType);
    return field?.value.length ? String(field.value[0]) : undefined;
  }

  private importBankCard(
    record: VaultItem,
    cipher: CipherView,
    consumedFieldIndices: Set<number>,
  ): void {
    cipher.type = CipherType.Card;
    cipher.card = new CardView();

    for (let i = 0; i < record.fields.length; i++) {
      const field = record.fields[i];

      if (field.type === "paymentCard" && field.value.length > 0) {
        const card = field.value[0] as {
          cardNumber?: string;
          cardExpirationDate?: string;
          cardSecurityCode?: string;
        };
        cipher.card.number = card.cardNumber || undefined;
        cipher.card.code = card.cardSecurityCode || undefined;
        cipher.card.brand = CardView.getCardBrandByPatterns(cipher.card.number);

        if (card.cardExpirationDate) {
          const parts = card.cardExpirationDate.split("/");
          if (parts.length === 2) {
            cipher.card.expMonth = parts[0];
            cipher.card.expYear = parts[1];
          }
        }
        consumedFieldIndices.add(i);
      } else if (
        field.type === "text" &&
        field.label === "cardholderName" &&
        field.value.length > 0
      ) {
        cipher.card.cardholderName = String(field.value[0]);
        consumedFieldIndices.add(i);
      } else if (field.type === "pinCode" && field.value.length > 0) {
        this.addField(cipher, "PIN", String(field.value[0]), FieldType.Hidden);
        consumedFieldIndices.add(i);
      }
    }
  }

  private importIdentity(
    record: VaultItem,
    cipher: CipherView,
    consumedFieldIndices: Set<number>,
    assignAccountNumber: (identity: IdentityView, value: string) => void,
  ): void {
    cipher.type = CipherType.Identity;
    cipher.identity = new IdentityView();

    for (let i = 0; i < record.fields.length; i++) {
      const field = record.fields[i];
      if (field.value.length === 0) {
        continue;
      }

      if (field.type === "accountNumber") {
        assignAccountNumber(cipher.identity, String(field.value[0]));
        consumedFieldIndices.add(i);
      } else if (field.type === "name") {
        const { first, middle, last } = field.value[0] as {
          first?: string;
          middle?: string;
          last?: string;
        };
        cipher.identity.firstName = first || undefined;
        cipher.identity.middleName = middle || undefined;
        cipher.identity.lastName = last || undefined;
        consumedFieldIndices.add(i);
      }
    }
  }

  private importSshKey(
    record: VaultItem,
    cipher: CipherView,
    consumedFieldIndices: Set<number>,
  ): boolean {
    const keyPairIndex = record.fields.findIndex((f) => f.type === "keyPair" && f.value.length > 0);
    if (keyPairIndex === -1) {
      return false;
    }

    const keyPair = record.fields[keyPairIndex].value[0] as {
      privateKey?: string;
      publicKey?: string;
    };
    if (!keyPair.privateKey) {
      return false;
    }

    const passphrase = this.getFirstFieldValue(record, "password") ?? "";

    let keyView: SshKeyView | null = null;
    try {
      keyView = import_ssh_key(keyPair.privateKey, passphrase);
    } catch {
      this.logService.warning(`Unable to import SSH key (title: ${record.title})`);
      return false;
    }
    if (!keyView) {
      return false;
    }

    cipher.type = CipherType.SshKey;
    cipher.sshKey.privateKey = keyView.privateKey;
    cipher.sshKey.publicKey = keyView.publicKey;
    cipher.sshKey.keyFingerprint = keyView.fingerprint;

    consumedFieldIndices.add(keyPairIndex);

    // Extract host details if present
    for (let i = 0; i < record.fields.length; i++) {
      if (consumedFieldIndices.has(i)) {
        continue;
      }
      const field = record.fields[i];
      if (field.type === "host" && field.value.length > 0) {
        const { hostName, port } = field.value[0] as { hostName?: string; port?: string };
        this.addField(cipher, "Hostname", hostName);
        this.addField(cipher, "Port", port);
        consumedFieldIndices.add(i);
      }
    }

    return true;
  }

  private importFields(
    record: VaultItem,
    cipher: CipherView,
    consumedFieldIndices: Set<number>,
  ): void {
    // Process standard fields
    for (let i = 0; i < record.fields.length; i++) {
      if (consumedFieldIndices.has(i)) {
        continue;
      }
      const field = record.fields[i];
      this.importField(cipher, field);
    }

    // Process custom fields
    for (const field of record.custom) {
      this.importField(cipher, field);
    }
  }

  private collectReferences(item: VaultItem): void {
    const refs: Reference[] = [];

    for (const field of [...item.fields, ...item.custom]) {
      if (field.type.endsWith("Ref") && field.value.length > 0) {
        const type = field.type.slice(0, -3);
        for (const uid of field.value) {
          refs.push({ id: String(uid), type });
        }
      }
    }

    if (refs.length > 0) {
      this.references.set(item.id, refs);
    }
  }

  private resolveReferences(): void {
    for (const [uid, refs] of this.references) {
      const cipher = this.idToCipher.get(uid);
      if (!cipher) {
        continue;
      }

      for (const { id, type } of refs) {
        const refCipher = this.idToCipher.get(id);
        if (!refCipher) {
          continue;
        }

        const value = refCipher.fields?.find((f) => f.name === type)?.value;
        if (value) {
          this.addField(cipher, type, value);
        }
      }
    }
  }

  private importField(cipher: CipherView, field: VaultField): void {
    if (!field.value || field.value.length === 0) {
      return;
    }

    // Reference fields are resolved separately after all records are parsed
    if (field.type.endsWith("Ref")) {
      return;
    }

    if (this.tryImportArrayField(field.type, field.value, cipher)) {
      return;
    }

    const name = field.label || field.type;

    for (const value of field.value) {
      if (this.tryImportExpandingField(field.type, value, cipher)) {
        continue;
      }

      this.importSingleField(field.type, name, value, cipher);
    }
  }

  private tryImportArrayField(type: string, values: unknown[], cipher: CipherView): boolean {
    switch (type) {
      case "login":
        {
          for (const v of values) {
            const username = String(v);
            if (this.isNullOrWhitespace(username)) {
              continue;
            }
            if (cipher.type === CipherType.Login && !cipher.login.username) {
              cipher.login.username = username;
            } else {
              this.addField(cipher, "Username", username);
            }
          }
        }
        break;
      case "password":
        {
          for (const v of values) {
            const password = String(v);
            if (this.isNullOrWhitespace(password)) {
              continue;
            }
            if (cipher.type === CipherType.Login && !cipher.login.password) {
              cipher.login.password = password;
            } else {
              this.addField(cipher, "Password", password, FieldType.Hidden);
            }
          }
        }
        break;
      case "oneTimeCode":
        {
          const codes = values.map((v) => String(v));
          if (codes.length === 0) {
            break;
          }

          // Login has a dedicated TOTP field. First code goes there.
          if (cipher.type === CipherType.Login && !cipher.login.totp) {
            cipher.login.totp = codes.shift()!;
          }

          // Additional codes become hidden fields
          for (const code of codes) {
            this.addField(cipher, "TOTP", code, FieldType.Hidden);
          }
        }
        break;
      case "url":
        {
          for (const v of values) {
            const uri = String(v);
            if (this.isNullOrWhitespace(uri)) {
              continue;
            }

            if (cipher.type === CipherType.Login) {
              const uriView = new LoginUriView();
              uriView.uri = this.fixUri(uri) ?? undefined;
              if (!this.isNullOrWhitespace(uriView.uri)) {
                if (!cipher.login.uris) {
                  cipher.login.uris = [];
                }
                cipher.login.uris.push(uriView);
              }
            } else {
              this.addField(cipher, "URL", uri);
            }
          }
        }
        break;
      default:
        return false;
    }

    return true;
  }

  private tryImportExpandingField(type: string, value: unknown, cipher: CipherView): boolean {
    switch (type) {
      case "host":
        {
          const { hostName, port } = value as { hostName?: string; port?: string };
          this.addField(cipher, "Hostname", hostName);
          this.addField(cipher, "Port", port);
        }
        break;
      case "keyPair":
        {
          const { publicKey, privateKey } = value as {
            publicKey?: string;
            privateKey?: string;
          };
          this.addField(cipher, "Public key", publicKey);
          this.addField(cipher, "Private key", privateKey, FieldType.Hidden);
        }
        break;
      case "securityQuestion":
        {
          const { question, answer } = value as { question?: string; answer?: string };
          this.addField(cipher, "Security question", question);
          this.addField(cipher, "Security question answer", answer, FieldType.Hidden);
        }
        break;
      case "appFiller":
        // Ignored - Keeper internal field
        break;
      default:
        return false;
    }

    return true;
  }

  private importSingleField(type: string, name: string, value: unknown, cipher: CipherView): void {
    let importedValue = this.convertToFieldValue(value);
    let importedType = FieldType.Text;

    switch (type) {
      case "date":
      case "birthDate":
      case "expirationDate":
        importedValue = this.parseDate(value);
        break;
      case "name":
        {
          const { first, middle, last } = value as {
            first?: string;
            middle?: string;
            last?: string;
          };
          importedValue = [first, middle, last]
            .filter((x) => x)
            .join(" ")
            .trim();
        }
        break;
      case "address":
        {
          const { street1, street2, city, state, zip, country } = value as {
            street1?: string;
            street2?: string;
            city?: string;
            state?: string;
            zip?: string;
            country?: string;
          };
          importedValue = [street1, street2, city, state, zip, country]
            .filter((x) => x)
            .join(", ")
            .trim();
        }
        break;
      case "phone":
        {
          const { region, number, ext, type } = value as {
            region?: string;
            number?: string;
            ext?: string;
            type?: string;
          };
          const parts: string[] = [];
          if (region) {
            parts.push(`(${region})`);
          }
          if (number) {
            parts.push(number);
          }
          if (ext) {
            parts.push(`ext. ${ext}`);
          }
          if (type) {
            parts.push(`(${type})`);
          }
          importedValue = parts.join(" ").trim();
        }
        break;
      case "bankAccount":
        {
          const { accountType, otherType, accountNumber, routingNumber } = value as {
            accountType?: string;
            otherType?: string;
            accountNumber?: string;
            routingNumber?: string;
          };
          const parts: string[] = [];
          const acctType = otherType || accountType;
          if (acctType) {
            parts.push(`Type: ${acctType}`);
          }
          if (accountNumber) {
            parts.push(`Account Number: ${accountNumber}`);
          }
          if (routingNumber) {
            parts.push(`Routing Number: ${routingNumber}`);
          }
          importedValue = parts.join(", ").trim();
        }
        break;
      case "pinCode":
      case "secret":
        importedType = FieldType.Hidden;
        break;
      default:
        break;
    }

    this.addField(cipher, name, importedValue, importedType);
  }

  private addField(
    cipher: CipherView,
    name: string,
    value: string | undefined | null,
    type: FieldType = FieldType.Text,
  ): void {
    if (!value) {
      return;
    }

    const field = new FieldView();
    field.type = type;
    field.name = name;
    field.value = this.convertToFieldValue(value);
    cipher.fields.push(field);
  }

  private convertToFieldValue(value: unknown): string {
    if (typeof value === "string") {
      return value;
    }

    try {
      return JSON.stringify(value);
    } catch {
      // Fallthrough
    }

    return "";
  }

  private parseDate(value: unknown): string {
    const date = new Date(value as string | number);
    return isNaN(date.getTime()) ? "" : date.toLocaleString();
  }
}

function mapVaultErrorReason(reason: VaultRecordErrorReason): ImportRecordErrorReason {
  switch (reason) {
    case VaultRecordErrorReason.UnsupportedVersion:
      return ImportRecordErrorReason.UnsupportedFeature;
    case VaultRecordErrorReason.FolderDecryptionFailed:
      return ImportRecordErrorReason.FolderDecryptionFailed;
    case VaultRecordErrorReason.DecryptionFailed:
    default:
      return ImportRecordErrorReason.Error;
  }
}
