import { parseTtl, getHasValidTtl } from '../ttl/parse_ttl';
import { ParserInvalidArgumentsError } from '../errors/parsing_error_invalid_arguments';
import { getRecordNameUsingOrigin, zipArrayToObj } from './utils_parser';
import {
  ZoneRecordTypings as RecordTypings,
  ParsedTypings,
} from '../shared/types_domain_specific';
import {
  SUPPORTED_RECORD_TYPES,
  ErrorKeyKind,
  RecordMinLengthKind,
  RecordMaxLengthKind,
  DirectiveKind,
  RecordType,
} from '../shared/constants_domain_specific';
import {
  ParserTypings,
  InputTypings,
  ParsingRecordDataType,
} from './types_parser';

/** nameAndTtl, values, min, max */
type Lengths = [number, number, number, number];

/** we indexed how long each value must be, so subtract $NAME_TTL + $IN_TYPE + $VALUES */
const getLengths = (
  recordType: RecordType,
  values: unknown[],
  lineParts: string[]
): Lengths => {
  const valuesLength = values.length;
  const minLength = RecordMinLengthKind[recordType];
  const maxLength = RecordMaxLengthKind[recordType];
  const nameAndTtlLength = lineParts.length - valuesLength - 2;

  return [nameAndTtlLength, valuesLength, minLength, maxLength];
};

/**
 * @idea we can include this in validation so it can be extended
 * @note this uses Lengths type (everything after nameAndTtl)
 * @throws {ParserInvalidArgumentsError} with invalid input lengths
 */
export const assertRecordLengths = (
  recordType: RecordType,
  valuesLength: number,
  minLength: number,
  maxLength: number
) => {
  const isExact = +minLength === +maxLength;
  const isExactAndNotEq = isExact && minLength !== valuesLength;

  if (isExactAndNotEq || minLength > valuesLength) {
    throw new ParserInvalidArgumentsError(recordType, valuesLength, minLength);
  }
};

/**
 * $NAME_TTL = [name] [ttl]
 * $IN_TYPE = `IN $TYPE`
 *
 * [name] [ttl] - IN $TYPE - value < 4
 *        [ttl] - IN $TYPE - value < 3
 *              - IN $TYPE - value < 2
 *
 * [ttl] [name] - IN TYPE - value < this is not supported by prod
 *
 * these 2 are where it gets tricky - do we prefer name, or ttl?
 * [name] - IN TYPE - value
 * [ttl] - IN TYPE - value
 *
 * @perf bench & experiment as a class
 * @note has sideEffects - may swap ttl order in array
 */
const normalizeRecord = (
  lineContent: string,
  lineParts: string[],
  recordType: InputTypings.SupportedRecordType,
  zoneObj: ParsedTypings.Obj,
  previousName: string
): ParsingRecordDataType => {
  /** add `IN` if it does not exist */
  const typeIndex = lineParts.lastIndexOf(recordType);

  let newName: string = '';

  if (typeIndex === 0 || lineParts[typeIndex - 1] !== 'IN') {
    lineParts.splice(typeIndex, 0, 'IN');
  }
  /** remove duplicate `IN` */
  const firstInIndex = lineParts.indexOf('IN');
  const lastInIndex = lineParts.lastIndexOf('IN');

  if (firstInIndex !== lastInIndex) {
    lineParts.splice(firstInIndex, 1);
  }

  const values = lineParts.slice(lineParts.lastIndexOf(recordType) + 1);
  const [nameAndTtlLength, ...rest] = getLengths(recordType, values, lineParts);
  assertRecordLengths(recordType, ...rest);

  /**
   * @todo @perf move to separate fn
   * @spec RFC-1035: <lineContent> contents are oneOf:
   * @example
   *   `[<TTL>] [<class>] <type> <RDATA>` // assume this one
   *   `[<class>] [<TTL>] <type> <RDATA>` // but also support this one
   */
  const getHas = () => {
    switch (nameAndTtlLength) {
      // we have name || ttl
      case 1: {
        // does not start with a space (if so, then it's a child of the parent [as long as parent is same type])
        const hasName = /^\s+/.test(lineContent) === false;

        return {
          hasTtl: !hasName,
          hasName,
        };
      }
      // ([name, ttl] | [ttl, name]) => [name, ttl]
      case 2: {
        const [first, second] = lineParts;
        const didFirstHaveTtl = getHasValidTtl(first);
        const didSecondHaveTtl = getHasValidTtl(second);
        const hasTtl = didFirstHaveTtl || didSecondHaveTtl;

        // swap order
        if (didFirstHaveTtl && !didSecondHaveTtl) {
          lineParts[0] = second;
          lineParts[1] = first;
        }

        return {
          hasTtl,
          hasName: lineParts[0] !== '',
        };
      }
      // no name or ttl, we can use the top level
      case 0: {
        return {
          hasTtl: false,
          hasName: false,
        };
      }
      // too many args for name + ttl
      default: {
        throw new Error(recordType);
      }
    }
  };

  const { hasName, hasTtl } = getHas();
  const normalized = {
    values,
    recordType,
    tokens: lineParts,
  };

  // unshift name
  if (!hasName) {
    const recordsSoFar = zoneObj[recordType];

    /** @idea simplify by removing the else if here since we have previousName now */
    if (previousName) {
      normalized.tokens.unshift(previousName);
    } else if (Array.isArray(recordsSoFar) && recordsSoFar.length > 0) {
      normalized.tokens.unshift(
        recordsSoFar[recordsSoFar.length - 1].name || '@'
      );
    } else {
      normalized.tokens.unshift('@');
    }
  } else {
    newName = normalized.tokens[0];
  }

  // unshift ttl
  if (hasTtl) {
    normalized.tokens[1] = `${parseTtl(normalized.tokens[1])[0]}`;
  } else {
    // move name to 0, then put ttl at 1
    normalized.tokens.unshift(normalized.tokens[0]);
    // we want the tokens to stay as strings, so we cast the numerical $ttl to a string
    // if there is not a ttl, we have added an `undefined` to this array
    normalized.tokens[1] =
      zoneObj.$TTL === undefined ? (undefined as any) : `${zoneObj.$TTL}`;
  }

  const [nameValue, ttlValue] = normalized.tokens;
  const finalName = getRecordNameUsingOrigin(nameValue, zoneObj);

  return {
    ...normalized,
    name: finalName,
    ttl: typeof ttlValue === 'string' ? +ttlValue : undefined,
    previousName: newName || previousName,
  };
};

const fromAtSymbolValuesToOrigin = <
  Record extends ParsedTypings.ByType[keyof ParsedTypings.ByType]
>(
  { values }: Record,
  { zoneObj }: ParserTypings.Parser
) => {
  return values.map(x => {
    // replace @ with origin
    if (x === '@' && zoneObj.$ORIGIN) return zoneObj.$ORIGIN;
    // escaped values
    else if (x === '\\@') return '@';
    // nothing changed
    return x;
  }) as Record['values'];
};

export const parsers = {
  NS: zipArrayToObj<RecordTypings.NS>('host', (record, parser) => ({
    ...record,
    values: fromAtSymbolValuesToOrigin<RecordTypings.NS>(record, parser),
  })),
  A: zipArrayToObj<RecordTypings.A>('ip', (record, parser) => ({
    ...record,
    values: fromAtSymbolValuesToOrigin<RecordTypings.A>(record, parser),
  })),
  AAAA: zipArrayToObj<RecordTypings.AAAA>('ip', (record, parser) => ({
    ...record,
    values: fromAtSymbolValuesToOrigin<RecordTypings.AAAA>(record, parser),
  })),
  CNAME: zipArrayToObj<RecordTypings.CNAME>('alias', (record, parser) => ({
    ...record,
    values: fromAtSymbolValuesToOrigin<RecordTypings.CNAME>(record, parser),
  })),
  MX: zipArrayToObj<RecordTypings.MX>('preference host', (record, parser) => ({
    ...record,
    values: fromAtSymbolValuesToOrigin<RecordTypings.MX>(record, parser),
  })),
  TXT: zipArrayToObj<RecordTypings.TXT>('txt'),
  PTR: zipArrayToObj<RecordTypings.PTR>('host', (record, parser) => ({
    ...record,
    values: fromAtSymbolValuesToOrigin<RecordTypings.PTR>(record, parser),
  })),
  SRV: zipArrayToObj<RecordTypings.SRV>('target port weight priority'),
  NAPTR: zipArrayToObj<RecordTypings.NAPTR>(
    'order preference flags services regexp replacement'
  ),
  SPF: zipArrayToObj<RecordTypings.SPF>('data', x => ({
    ...x,
    values: [x.values.join(' ').trim()],
  })),
  CAA: zipArrayToObj<RecordTypings.CAA>('flags tag data', x => ({
    ...x,
    data: x.data.replace(/^"(.+?)"$/, '$1'),
  })),
  SOA: zipArrayToObj<RecordTypings.SOA>(
    'minimum expire retry refresh serial rname mname'
  ) as (
    x: Omit<ParsingRecordDataType, 'name' | 'previousName'>
  ) => RecordTypings.SOA,
} as const;

const originVisitor: ParserTypings.Visitor = {
  isSatisfied: ({ lineParts }) => lineParts.indexOf('$ORIGIN') === 0,
  visit(parser, { lineParts, addMeta }) {
    parser.zoneObj.$ORIGIN = lineParts[1];
    return addMeta({
      instructionType: DirectiveKind.Origin,
      value: lineParts[1],
    });
  },
};
const ttlVisitor: ParserTypings.Visitor = {
  isSatisfied: ({ lineParts }) => lineParts.indexOf('$TTL') === 0,
  visit({ zoneObj }, { addMeta, lineParts }) {
    const [ttl, error] = parseTtl(lineParts[1]);

    if (!error) {
      zoneObj.$TTL = ttl;
      addMeta({
        instructionType: DirectiveKind.TimeToLive,
        value: ttl,
      });
    } else {
      addMeta({
        instructionType: DirectiveKind.TimeToLive,
        value: ttl,
        errorType: error,
        error,
      });
    }
  },
};

const soaVisitor: ParserTypings.Visitor = {
  isSatisfied: ({ lineParts }) => lineParts.includes('SOA'),
  visit({ zoneObj }, { addMeta, lineParts }) {
    zoneObj.SOA = parsers.SOA({
      tokens: lineParts,
      values: lineParts.slice(lineParts.indexOf('SOA') + 1),
    });
    addMeta({ instructionType: 'SOA', value: zoneObj.SOA });
  },
};

/**
 * intersection.
 * checks whether the array of strings include any of the supported record types
 */
const getSupportedType = (lineParts: string[]) =>
  SUPPORTED_RECORD_TYPES.find(supportedType =>
    lineParts.includes(supportedType)
  );

const simpleRecordsVisitor: ParserTypings.Visitor = {
  isSatisfied: ({ lineParts }) => !!getSupportedType(lineParts),
  visit(parser, { addMeta, lineParts, lineContent }) {
    const { zoneObj, stack } = parser;
    const type = getSupportedType(lineParts) as RecordType;

    // make sure the list exists
    if (!Array.isArray(zoneObj[type])) {
      zoneObj[type] = [] as any[];
    }
    // reference the array and because we defaulted it above, assert the type
    const listForType = zoneObj[type] as RecordTypings.AnyIndexSignature[];

    try {
      const previousName = stack[stack.length - 1]?.name ?? '';
      const normalized = normalizeRecord(
        lineContent,
        lineParts,
        type,
        zoneObj,
        previousName
      );

      stack.push(normalized);
      const parsed = parsers[type](normalized, parser);
      listForType.push(parsed);
      addMeta({ instructionType: type, value: parsed });
    } catch (error) {
      addMeta({
        instructionType: type,
        value: lineParts.join(' '),
        error,
        errorType:
          error instanceof ParserInvalidArgumentsError
            ? ErrorKeyKind.WrongLength
            : ErrorKeyKind.WrongNameArgsLength,
      });
    }
  },
};

const fallbackVisitor: ParserTypings.Visitor = {
  isSatisfied: () => true,
  visit(parser, { addMeta, lineParts }) {
    addMeta({
      instructionType: ErrorKeyKind.Unknown,
      errorType: ErrorKeyKind.BadDirective,
      value: lineParts.join(' '),
    });
  },
};

export const visitors = [
  originVisitor,
  ttlVisitor,
  soaVisitor,
  simpleRecordsVisitor,
  fallbackVisitor,
];
