import {createParser} from 'css-selector-parser';
import type {
  AstAttribute,
  AstClassName,
  AstId,
  AstPseudoClass,
  AstRule,
  AstSelector,
  AstTagName,
} from 'css-selector-parser';
import {errors} from 'appium/driver';
import {log} from './logger';
import {escapeRegExp, isEmpty} from './utils';

const parseCssSelector = createParser({
  syntax: {
    pseudoClasses: {
      unknown: 'accept',
      definitions: {
        Selector: ['has'],
      },
    },
    combinators: ['>', '+', '~'],
    attributes: {
      operators: ['^=', '$=', '*=', '~=', '='],
    },
    ids: true,
    classNames: true,
    tag: {
      wildcard: true,
    },
  },
  substitutes: true,
});

const RESOURCE_ID = 'resource-id';
const ID_LOCATOR_PATTERN = /^[a-zA-Z_][a-zA-Z0-9._]*:id\/[\S]+$/;

const BOOLEAN_ATTRS = [
  'checkable',
  'checked',
  'clickable',
  'enabled',
  'focusable',
  'focused',
  'long-clickable',
  'scrollable',
  'selected',
] as const;

const NUMERIC_ATTRS = ['index', 'instance'] as const;

const STR_ATTRS = ['description', RESOURCE_ID, 'text', 'class-name', 'package-name'] as const;

const ALL_ATTRS = [...BOOLEAN_ATTRS, ...NUMERIC_ATTRS, ...STR_ATTRS] as readonly string[];

const ATTRIBUTE_ALIASES: Array<[string, string[]]> = [
  [RESOURCE_ID, ['id']],
  ['description', ['content-description', 'content-desc', 'desc', 'accessibility-id']],
  ['index', ['nth-child']],
];

const isAstAttribute = (item: {type?: string}): item is AstAttribute => item.type === 'Attribute';
const isAstPseudoClass = (item: {type?: string}): item is AstPseudoClass =>
  item.type === 'PseudoClass';
const isAstClassName = (item: {type?: string}): item is AstClassName => item.type === 'ClassName';
const isAstTagName = (item: {type?: string}): item is AstTagName => item.type === 'TagName';
const isAstId = (item: {type?: string}): item is AstId => item.type === 'Id';

export class CssConverter {
  constructor(
    private readonly selector: string,
    private readonly pkg?: string | null,
  ) {}

  toUiAutomatorSelector(): string {
    let cssObj: AstSelector;
    try {
      cssObj = parseCssSelector(this.selector) as AstSelector;
    } catch (e: any) {
      log.debug(e.stack);
      throw new errors.InvalidSelectorError(
        `Invalid CSS selector '${this.selector}'. Reason: '${e.message}'`,
      );
    }
    try {
      return this.parseCssObject(cssObj);
    } catch (e: any) {
      log.debug(e.stack);
      throw new errors.InvalidSelectorError(
        `Unsupported CSS selector '${this.selector}'. Reason: '${e.message}'`,
      );
    }
  }

  private formatIdLocator(locator: string): string {
    return ID_LOCATOR_PATTERN.test(locator) ? locator : `${this.pkg || 'android'}:id/${locator}`;
  }

  private parseAttr(cssAttr: AstAttribute): string {
    const attrValueNode = cssAttr.value as {value?: string} | undefined;
    const attrValue = attrValueNode?.value;
    if (typeof attrValue !== 'string' && !isEmpty(attrValue)) {
      throw new Error(
        `'${cssAttr.name}=${attrValue}' is an invalid attribute. Only 'string' and empty attribute types are supported. Found '${attrValue}'`,
      );
    }
    const attrName = requireEntityName(cssAttr);
    const methodName = toSnakeCase(attrName);

    if (
      !STR_ATTRS.includes(attrName as (typeof STR_ATTRS)[number]) &&
      !BOOLEAN_ATTRS.includes(attrName as (typeof BOOLEAN_ATTRS)[number])
    ) {
      throw new Error(
        `'${attrName}' is not supported. Supported attributes are '${[...STR_ATTRS, ...BOOLEAN_ATTRS].join(', ')}'`,
      );
    }

    if (BOOLEAN_ATTRS.includes(attrName as (typeof BOOLEAN_ATTRS)[number])) {
      return `.${methodName}(${requireBoolean(cssAttr)})`;
    }

    let value = attrValue || '';
    if (attrName === RESOURCE_ID) {
      value = this.formatIdLocator(value);
    }
    if (value === '') {
      return `.${methodName}Matches("")`;
    }

    switch (cssAttr.operator) {
      case '=':
        return `.${methodName}("${value}")`;
      case '*=':
        if (['description', 'text'].includes(attrName)) {
          return `.${methodName}Contains("${value}")`;
        }
        return `.${methodName}Matches("${escapeRegExp(value)}")`;
      case '^=':
        if (['description', 'text'].includes(attrName)) {
          return `.${methodName}StartsWith("${value}")`;
        }
        return `.${methodName}Matches("^${escapeRegExp(value)}")`;
      case '$=':
        return `.${methodName}Matches("${escapeRegExp(value)}$")`;
      case '~=':
        return `.${methodName}Matches("${getWordMatcherRegex(value)}")`;
      default:
        throw new Error(
          `Unsupported CSS attribute operator '${cssAttr.operator}'.  '=', '*=', '^=', '$=' and '~=' are supported.`,
        );
    }
  }

  private parsePseudo(cssPseudo: AstPseudoClass): string | undefined {
    const argValue = (cssPseudo.argument as {value?: string} | undefined)?.value;
    if (typeof argValue !== 'string' && !isEmpty(argValue)) {
      throw new Error(
        `'${cssPseudo.name}=${argValue}'. Unsupported css pseudo class value: '${argValue}'. Only 'string' type or empty is supported.`,
      );
    }

    const pseudoName = requireEntityName(cssPseudo);

    if (BOOLEAN_ATTRS.includes(pseudoName as (typeof BOOLEAN_ATTRS)[number])) {
      return `.${toSnakeCase(pseudoName)}(${requireBoolean(cssPseudo)})`;
    }

    if (NUMERIC_ATTRS.includes(pseudoName as (typeof NUMERIC_ATTRS)[number])) {
      return `.${pseudoName}(${argValue})`;
    }
  }

  private parseCssRule(cssRule: AstRule): string {
    if (cssRule.combinator && ![' ', '>'].includes(cssRule.combinator)) {
      throw new Error(
        `'${cssRule.combinator}' is not a supported combinator. Only child combinator (>) and descendant combinator are supported.`,
      );
    }

    const uiAutomatorSelector: string[] = ['new UiSelector()'];
    const items = cssRule.items ?? [];

    const astClassNames = items.filter(isAstClassName);
    const classNames = astClassNames.map(({name}) => name);

    const astTag = items.find(isAstTagName);
    const tagName = astTag?.name;
    if (tagName && tagName !== '*') {
      const androidClass = [tagName];
      if (classNames.length) {
        for (const cssClassNames of classNames) {
          androidClass.push(cssClassNames);
        }
        uiAutomatorSelector.push(`.className("${androidClass.join('.')}")`);
      } else {
        uiAutomatorSelector.push(`.classNameMatches("${tagName}")`);
      }
    } else if (classNames.length) {
      uiAutomatorSelector.push(`.classNameMatches("${classNames.join('\\.')}")`);
    }

    const astIds = items.filter(isAstId);
    const ids = astIds.map(({name}) => name);
    if (ids.length) {
      uiAutomatorSelector.push(`.resourceId("${this.formatIdLocator(ids[0])}")`);
    }

    const attributes = items.filter(isAstAttribute);
    for (const attr of attributes) {
      uiAutomatorSelector.push(this.parseAttr(attr));
    }

    const pseudoClasses = items.filter(isAstPseudoClass);
    for (const pseudo of pseudoClasses) {
      const sel = this.parsePseudo(pseudo);
      if (sel) {
        uiAutomatorSelector.push(sel);
      }
    }
    if (cssRule.nestedRule) {
      uiAutomatorSelector.push(`.childSelector(${this.parseCssRule(cssRule.nestedRule)})`);
    }
    return uiAutomatorSelector.join('');
  }

  private parseCssObject(css: AstSelector): string {
    if (!isEmpty(css.rules)) {
      return this.parseCssRule(css.rules[0] as AstRule);
    }

    throw new Error('No rules could be parsed out of the current selector');
  }
}

function toSnakeCase(str?: string | null): string {
  if (!str) {
    return '';
  }
  const tokens = str
    .split('-')
    .map((token) => token.charAt(0).toUpperCase() + token.slice(1).toLowerCase());
  const out = tokens.join('');
  return out.charAt(0).toLowerCase() + out.slice(1);
}

function requireBoolean(css: AstAttribute | AstPseudoClass): 'true' | 'false' {
  const rawValue = (css as any).value?.value ?? (css as any).argument?.value;
  const value = String(rawValue ?? 'true').toLowerCase();
  if (value === 'true') {
    return 'true';
  }
  if (value === 'false') {
    return 'false';
  }
  throw new Error(`'${css.name}' must be true, false or empty. Found '${(css as any).value}'`);
}

function requireEntityName(cssEntity: AstAttribute | AstPseudoClass): string {
  const attrName = cssEntity.name.toLowerCase();

  if (ALL_ATTRS.includes(attrName)) {
    return attrName;
  }

  for (const [officialAttr, aliasAttrs] of ATTRIBUTE_ALIASES) {
    if (aliasAttrs.includes(attrName)) {
      return officialAttr;
    }
  }
  throw new Error(
    `'${attrName}' is not a valid attribute. Supported attributes are '${ALL_ATTRS.join(', ')}'`,
  );
}

function getWordMatcherRegex(word: string): string {
  return `\\b(\\w*${escapeRegExp(word)}\\w*)\\b`;
}
