import { fileURLToPath } from 'node:url';
import { styleText } from 'node:util';
import fs from 'fs';
import { Validator, type Schema } from 'jsonschema';
import path from 'path';
import shell from 'shelljs';
import { dump as dumpYaml } from 'js-yaml';
import marky from 'marky';
import { LocationConflation } from '@rapideditor/location-conflation';
import { createRequire } from 'module';
import { compile, toSafeIdentifier } from 'json-schema-to-typescript-lite';
import type * as Taginfo from 'taginfo-projects';
import { isReference, dereferencedTranslatableContent, dereferenceUntranslatedContent } from './references.ts';
import fetchTranslations, { expandTStrings, sortObject } from './translations.ts';
import type { Field, Geometry, Preset, AllFields, AllPresets, References, TStrings, Options, AllCategories, TaginfoTag, TaginfoSchema, PresetDefaults, Deprecated, Discarded, SourceStrings, PresetCategory } from './types.def.ts';

const require = createRequire(import.meta.url);

const fieldSchema = require('../../schemas/field.json');
const presetSchema = require('../../schemas/preset.json');
const categorySchema = require('../../schemas/preset_category.json');
const defaultsSchema = require('../../schemas/preset_defaults.json');
const deprecatedSchema = require('../../schemas/deprecated.json');
const discardedSchema = require('../../schemas/discarded.json');

let _currBuild: Promise<void> | null = null;

const jsonschema = new Validator();
const locationConflation = new LocationConflation();

function validateData(options?: Options) {
  const START = '🔬  ' + styleText('yellow', 'Validating schema...');
  const END = '👍  ' + styleText('green', 'schema okay');

  process.stdout.write('\n');
  process.stdout.write(START + '\n');
  marky.mark(END);

  processData(options, 'validate');

  marky.stop(END);
  process.stdout.write('\n');
}

function buildDev(options?: Options) {

  if (_currBuild) return _currBuild;

  const START = '🏗   ' + styleText('yellow', 'Validating and building for development...');
  const END = '👍  ' + styleText('green', 'built for development');

  process.stdout.write('\n');
  process.stdout.write(START + '\n');
  marky.mark(END);

  processData(options, 'build-interim');

  marky.stop(END);
  process.stdout.write('\n');
}

function buildDist(options?: Partial<Options>) {

  if (_currBuild) return _currBuild;

  const START = '🏗   ' + styleText('yellow', 'Validating and building dist files...');
  const END = '👍  ' + styleText('green', 'dist files built');

  process.stdout.write('\n');
  process.stdout.write(START + '\n');
  marky.mark(END);

  return _currBuild = processData(options, 'build-dist')
    .then(() => {
      marky.stop(END);
      process.stdout.write('\n');
      _currBuild = null;
    })
    .catch((err) => {
      console.error(err);
      _currBuild = null;
      process.exit(1);
    });
}

async function processData(_options: Partial<Options> | undefined, type: string) {
  const options: Options = {
    inDirectory: 'data',
    interimDirectory: 'interim',
    outDirectory: 'dist',
    sourceLocale: 'en',
    processCategories: null,
    processFields: null,
    processPresets: null,
    listReusedIcons: false,
    ..._options,
  };

  const dataDir = './' + options.inDirectory;

  // Translation strings
  let tstrings: TStrings = {
    categories: {},
    fields: {},
    presets: {}
  };

  // all fields searchable under "add field"
  let searchableFieldIDs: Record<string, true> = {};

  const deprecated = read<Deprecated>(dataDir + '/deprecated.json');
  if (deprecated) {
    validateSchema(dataDir + '/deprecated.json', deprecated, deprecatedSchema);
  }

  const discarded = read<Discarded>(dataDir + '/discarded.json');
  if (discarded) {
    validateSchema(dataDir + '/discarded.json', discarded, discardedSchema);
  }

  let categories = generateCategories(dataDir, tstrings);
  if (options.processCategories) options.processCategories(categories);

  const references: References = { fields: {}, presets: {} };
  let fields = generateFields(dataDir, tstrings, searchableFieldIDs, references);
  if (options.processFields) options.processFields(fields);

  let presets = generatePresets(dataDir, tstrings, searchableFieldIDs, options.listReusedIcons, references);
  if (options.processPresets) options.processPresets(presets);

  // Additional consistency checks
  validateCategoryPresets(categories, presets);
  validatePresetFields(presets, fields);

  dereferenceUntranslatedContent(presets, fields, references);

  const defaults = read<PresetDefaults>(dataDir + '/preset_defaults.json');
  if (defaults) {
    validateSchema(dataDir + '/preset_defaults.json', defaults, defaultsSchema);
    validateDefaults(defaults, categories, presets);
  }

  if (type.indexOf('build') !== 0) return;

  const sourceLocale = options.sourceLocale;

  const interimDir = './' + options.interimDirectory;
  if (!fs.existsSync(interimDir)) fs.mkdirSync(interimDir);
  shell.rm('-f', [interimDir + '/*']); // clean directory

  let translations = generateTranslations(fields, presets, tstrings, searchableFieldIDs);

  let translationsForYaml: SourceStrings = {};
  translationsForYaml[sourceLocale] = { presets: translations };
  fs.writeFileSync(interimDir + '/source_strings.yaml', translationsToYAML(translationsForYaml));

  let icons = generateIconsList(presets, fields, categories);
  fs.writeFileSync(interimDir + '/icons.json', JSON.stringify(icons, null, 4));

  dereferencedTranslatableContent(tstrings, references, true);

  if (type !== 'build-dist') return;

  const doFetchTranslations = options.translOrgId && options.translProjectId;

  const distDir = './' + options.outDirectory;
  if (!fs.existsSync(distDir)) fs.mkdirSync(distDir);
  // clean directory
  shell.rm('-f', [distDir + '/*.*']);
  if (doFetchTranslations) {
    shell.rm('-rf', [distDir + '/translations']);
  }

  categories = sortObject(categories);
  fields = sortObject(fields);
  presets = sortObject(presets);

  fs.writeFileSync(distDir + '/preset_categories.json', JSON.stringify(categories, null, 4));
  fs.writeFileSync(distDir + '/fields.json', JSON.stringify(fields, null, 4));
  fs.writeFileSync(distDir + '/presets.json', JSON.stringify(presets, null, 4));

  let taginfo = generateTaginfo(presets, fields, deprecated, discarded, tstrings, options.taginfoProjectInfo!);
  if (taginfo) fs.writeFileSync(distDir + '/taginfo.json', JSON.stringify(taginfo, null, 4));

  if (defaults) fs.writeFileSync(distDir + '/preset_defaults.json', JSON.stringify(defaults, null, 4));
  if (deprecated) fs.writeFileSync(distDir + '/deprecated.json', JSON.stringify(deprecated, null, 4));
  if (discarded) fs.writeFileSync(distDir + '/discarded.json', JSON.stringify(discarded, null, 4));

  expandTStrings(tstrings);
  let translationsForJson: typeof translationsForYaml = {};
  translationsForJson[sourceLocale] = { presets: tstrings };

  if (!fs.existsSync(distDir + '/translations')) fs.mkdirSync(distDir + '/translations');
  fs.writeFileSync(distDir + '/translations/' + sourceLocale + '.json', JSON.stringify(translationsForJson, null, 4));

  const tasks = [
    // Minify files
    minifyJSON(distDir + '/preset_categories.json', distDir + '/preset_categories.min.json'),
    minifyJSON(distDir + '/fields.json', distDir + '/fields.min.json'),
    minifyJSON(distDir + '/presets.json', distDir + '/presets.min.json'),
    minifyJSON(distDir + '/taginfo.json', distDir + '/taginfo.min.json'),
    minifyJSON(distDir + '/preset_defaults.json', distDir + '/preset_defaults.min.json'),
    minifyJSON(distDir + '/deprecated.json', distDir + '/deprecated.min.json'),
    minifyJSON(distDir + '/discarded.json', distDir + '/discarded.min.json'),
    minifyJSON(distDir + '/translations/' + sourceLocale + '.json', distDir + '/translations/' + sourceLocale + '.min.json'),
    generateTypeDefs(distDir),
  ];

  if (doFetchTranslations) {
    tasks.push(fetchTranslations(options, references));
  }
  return Promise.all(tasks);
}


function read<T>(f: string): T {
  return fs.existsSync(f) && JSON.parse(fs.readFileSync(f, 'utf8'));
}


function validateSchema(file: string, instance: unknown, schema: Schema) {
  // add this schema to the cache, so $ref can be resolved faster
  jsonschema.addSchema(schema);

  let validationErrors = jsonschema.validate(instance, schema).errors;

  if (validationErrors.length) {
    process.stderr.write(`${file}: \n`);
    validationErrors.forEach(error => {
      if (error.property) {
        process.stderr.write(error.property + ' ' + error.message + '\n');
      } else {
        process.stderr.write(error + '\n');
      }
    });
    process.stdout.write('\n');
    process.exit(1);
  }
}


function generateCategories(dataDir: string, tstrings: TStrings) {
  let categories: AllCategories = {};

  const categoriesDir = path.posix.join(dataDir, 'preset_categories');
  fs.globSync(categoriesDir + '/*.json').forEach(file => {
    let category = read<PresetCategory>(file);
    const id = 'category-' + extractIdFromPath(categoriesDir, file);

    validateSchema(file, category, categorySchema);

    tstrings.categories[id] = { name: category.name };
    // @ts-expect-error -- deleting a non-optional property
    delete category.name;

    categories[id] = category;
  });

  return categories;
}


function generateFields(dataDir: string, tstrings: TStrings, searchableFieldIDs: Record<string, true>, references: References) {
  let fields: AllFields = {};

  const fieldsDir = path.posix.join(dataDir, 'fields');
  fs.globSync(fieldsDir + '/**/*.json').forEach(file => {
    let field = read<Field>(file);
    const id = extractIdFromPath(fieldsDir, file);

    validateSchema(file, field, fieldSchema);

    tstrings.fields[id] = {};
    let t = tstrings.fields[id];

    const label = field.label;

    if (isReference(label)) {
      references.fields[id] ||= {};
      references.fields[id].labelAndTerms = label;
    } else {
      t.label = label;
    }
    // @ts-expect-error -- deleting a non-optional property
    delete field.label;

    validateTerms(field.terms, `field "${id}"`);
    tstrings.fields[id].terms = Array.from(new Set(
      (field.terms || [])
        .map(t => t.toLowerCase().trim())
        .filter(Boolean)
    )).join(',');
    delete field.terms;

    if (field.universal) {
      searchableFieldIDs[id] = true;
    }

    if (field.placeholder) {
      if (isReference(field.placeholder)) {
        references.fields[id] ||= {};
        references.fields[id].placeholder = field.placeholder;
      } else {
        t.placeholder = field.placeholder;
      }
      delete field.placeholder;
    }

    if (field.strings) {
      for (let _prop in field.strings) {
        const prop = _prop as keyof typeof field.strings;
        t[prop] = {};
        for (const [key, value] of Object.entries(field.strings[prop]!)) {
          if (typeof value === 'string' && isReference(value)) {
            references.fields[id] ||= {};
            references.fields[id].options ||= {};
            references.fields[id].options[prop] ||= {};
            references.fields[id].options[prop][key] = value;
          } else {
            t[prop][key] = value;
          }
        }
      }
      if (!field.options && field.strings.options) {
        field.options = Object.keys(field.strings.options);
      }
      delete field.strings;
    }

    if (field.stringsCrossReference) {
      references.fields[id] ||= {};
      references.fields[id].stringsCrossReference = field.stringsCrossReference;
      delete field.stringsCrossReference;
    }

    if (field.locationSet) {
      if (!field.locationSet.include) {
        field.locationSet.include = [
            'Planet'
        ];
      }

      locationConflation.validateLocationSet(field.locationSet);
    }

    fields[id] = field;
  });

  return fields;
}


/**
 * @example `data/presets/addr/_interpolation.json` -> `addr/interpolation`
 */
function extractIdFromPath(parentDir: string, file: string) {
  // Strip the leading directory
  const relativePath = path.relative(parentDir, file);
  const dirName = path.posix.dirname(relativePath);
  // Strip the extension, i.e. `.json`
  const extension = path.extname(relativePath);
  const fileName = path.posix.basename(relativePath, extension);
  // Build the id from only the subdirectories and base file name
  const id = path.posix.join(dirName, fileName);
  // Remove leading underscores from any directory or file name
  // Also enforce posix path separator '/' in case the path was with Windows '\' path separator
  return path.posix.join(...id.split(path.sep).map(s => s.replace(/^_/, '')));
}


function generatePresets(
    dataDir: string,
    tstrings: TStrings,
    searchableFieldIDs: Record<string, true>,
    listReusedIcons: boolean | number | undefined,
    references: References,
) {
  let presets: AllPresets = {};

  let icons: Record<string, string[]> = {};

  const presetsDir = path.posix.join(dataDir, 'presets');
  fs.globSync(presetsDir + '/**/*.json').forEach(file => {
    let preset = read<Preset>(file);
    const id = extractIdFromPath(presetsDir, file);

    if (presets[id] !== undefined) {
      process.stderr.write(`Preset with id "${id}" defined multiple times\n`);
      process.stdout.write('\n');
      process.exit(1);
    }

    validateSchema(file, preset, presetSchema);

    let names = new Set<string>([]);
    tstrings.presets[id] = {};

    if (isReference(preset.name)) {
      references.presets[id] ||= {};
      references.presets[id].nameTermsAliases = preset.name;
    } else {
      tstrings.presets[id].name = preset.name;
      names.add(preset.name.toLowerCase());
    }

    preset.aliases = Array.from(new Set(
      (preset.aliases || [])
      .map(t => t.trim())
      .filter(Boolean)
      .filter(t => !names.has(t.toLowerCase()))
    ));
    preset.aliases.forEach(a => names.add(a.toLowerCase()));

    validateTerms(preset.terms, `preset "${id}"`);
    preset.terms = Array.from(new Set(
      (preset.terms || [])
        .map(t => t.toLowerCase().trim())
        .filter(Boolean)
        .filter(t => !names.has(t))
    ));

    if (preset.aliases.length) tstrings.presets[id].aliases = preset.aliases.join('\n');
    if (preset.terms.length) tstrings.presets[id].terms = preset.terms.join(',');

    // don't include localized strings in the presets dist file since they're already in the locale file
    delete preset.aliases;
    delete preset.terms;
    // @ts-expect-error -- deleting a non-optional prop
    delete preset.name;


    if (preset.moreFields) {
      preset.moreFields.forEach(fieldID => { searchableFieldIDs[fieldID] = true; });
    }

    presets[id] = preset;

    if (preset.searchable !== false) {
      let icon = preset.icon || '(none)';
      if (!icons[icon]) icons[icon] = [];
      icons[icon].push(id);
    }

    if (preset.relation) {
      tstrings.presets[id].relation = {
        role_labels: preset.relation.role_labels
      };
        // @ts-expect-error -- deleting a non-optional prop
      delete preset.relation.role_labels;
    }
    if (preset.relationCrossReference) {
      references.presets[id] ||= {};
      references.presets[id].relation = preset.relationCrossReference;
      delete preset.relationCrossReference;
    }

    if (preset.locationSet) {
      if (!preset.locationSet.include) {
        preset.locationSet.include = [
            'Planet'
        ];
      }

      locationConflation.validateLocationSet(preset.locationSet);
    }
  });

  if (listReusedIcons) {
    const reuseLimit = typeof listReusedIcons === 'number' && listReusedIcons > 0 ? listReusedIcons : 1;

    let reusedIconPresetCount = 0;
    const reusedIcons = Object.keys(icons).filter(function(iconID) {
      const presetIDs = icons[iconID];
      if (presetIDs.length > reuseLimit) {
        reusedIconPresetCount += presetIDs.length;
        return true;
      }
      return false;
    });

    if (reusedIcons.length > 0) {
      process.stdout.write(reusedIcons.length + ' icon(s), including (none), are each used more than ' + reuseLimit + ' time(s), affecting ' + reusedIconPresetCount + ' presets\n');

      reusedIcons.sort(function(iconID1, iconID2) {
        return icons[iconID2].length - icons[iconID1].length;

      }).forEach(function(iconID) {
        const presetIDs = icons[iconID];
        process.stdout.write(iconID + ', ' + presetIDs.length + '\n');
        for (let i in presetIDs) {
          process.stdout.write('-' + presetIDs[i] + '\n');
        }
        process.stdout.write('\n');
      });
    } else {
      process.stdout.write(styleText('green', 'No icon is used more than ' + reuseLimit + ' time(s) across all searchable presets\n'));
    }
  }

  return presets;
}


function generateTranslations(fields: AllFields, presets: AllPresets, tstrings: TStrings, searchableFieldIDs: Record<string, true>) {
  let yamlStrings: TStrings = JSON.parse(JSON.stringify(tstrings));  // deep clone

  for (let fieldId in yamlStrings.fields) {
    let yamlField = yamlStrings.fields[fieldId];
    let field = fields[fieldId];
    let options = yamlField.options || {};
    let optkeys = Object.keys(options);

    if (field.keys) {
      yamlField['#label'] = field.keys.map(k => `${k}=*`).join(', ');
    } else if (field.key) {
      yamlField['#label'] = `${field.key}=*`;
    }
    optkeys.forEach(k => {
      if (typeof options[k] === 'string'){
        options['#' + k] = field.key ? `${field.key}=${k}` : `field "${fieldId}" with value "${k}"`;
      } else {
        options[k]['#description'] = `description for ${field.key}=${k}`;
        options[k]['#title'] = `title for ${field.key}=${k}`;
      }
    });

    if (field.locationSet) {
      if (field.locationSet.include) {
        yamlField['#label'] += ` | Local preset for countries ${field.locationSet.include.map(country => `"${country.toUpperCase()}"`).join(', ')}`;
      }
      if (field.locationSet.exclude) {
        yamlField['#label'] += ` | Hidden in some countries: ${field.locationSet.exclude.map(country => `"${country.toUpperCase()}"`).join(', ')}`;
      }
    }

    if (yamlField.placeholder) {
      yamlField['#placeholder'] = `${fieldId} field placeholder`;
    }

    if (searchableFieldIDs[fieldId]) {
      if (yamlField.terms) {
        yamlField['#terms'] = 'terms: ' + yamlField.terms;
      } else {
        delete tstrings.fields[fieldId].terms;
      }
      if (yamlField.label) {
        yamlField.terms = `[translate with synonyms or related terms for '${yamlField.label}', separated by commas]`;
      } else {
        delete yamlField.terms;
      }
    } else {
      delete tstrings.fields[fieldId].terms;
      delete yamlField.terms;
    }
  }

  for (let presetId in yamlStrings.presets) {
    let yamlPreset = yamlStrings.presets[presetId];
    let preset = presets[presetId];
    let tags = preset.tags || {};
    let keys = Object.keys(tags);

    const tagsString = keys.map(k => `${k}=${tags[k]}`).join(' + ');

    if (keys.length) {
      yamlPreset['#name'] = tagsString;
      if (yamlPreset.aliases) {
        yamlPreset['#name'] += ' | ' + (yamlPreset.aliases as string).split('\n').join(', ');
      }
      yamlPreset['#name'] += ' | Translate the primary name. Optionally, add equivalent synonyms on newlines in order of preference (press the Return key).';
    }

    if (preset.locationSet?.include) {
      yamlPreset['#name'] += ` | Local preset for countries ${preset.locationSet.include.map(country => `"${country.toUpperCase()}"`).join(', ')}`;
    }

    if (yamlPreset.relation?.role_labels) {
      for (const role in yamlPreset.relation.role_labels) {
        yamlPreset.relation.role_labels[`#${role}`] = `Relation role “${role}” when used with ${tagsString}`;
      }
    }

    if (preset.searchable !== false) {
      if (yamlPreset.terms) {
        yamlPreset['#terms'] = 'terms: ' + yamlPreset.terms;
      } else {
        delete yamlPreset.terms;
      }
      if (yamlPreset.name) {
        yamlPreset.terms = `<translate with synonyms or related terms for '${yamlPreset.name}', separated by commas>`;
      }
    } else {
      delete tstrings.presets[presetId].terms;
      delete yamlPreset.terms;
    }
    delete yamlPreset.aliases;
  }

  return yamlStrings;
}



function generateTaginfo(
    presets: AllPresets,
    fields: AllFields,
    deprecated: Deprecated,
    discarded: Discarded,
    tstrings: TStrings,
    projectInfo: Taginfo.Project,
) {

  const packageInfo = JSON.parse(fs.readFileSync('./package.json', 'utf8'));

  if (!projectInfo.name) projectInfo.name = packageInfo.name;
  if (!projectInfo.description) projectInfo.description = packageInfo.description;

  projectInfo.description +=
      ' The following mnemonics are used in individual tag descriptions to annotate in which context the respective tag is supported:' +
      ' 🄿: preset, 🄵 field, 🄵🅅: field value, 🄳: deprecated tag, 🄳🄳: discarded tag';
  const requiredProps = ['name', 'description', 'project_url', 'contact_name', 'contact_email'];
  for (let i in requiredProps) {
    if (!(requiredProps[i] in projectInfo)) {
      process.stdout.write(styleText('yellow', 'Cannot compile taginfo.json: missing required project property `' + requiredProps[i] + '`') + '\n');
      return null;
    }
  }

  let taginfo: TaginfoSchema = {
    data_format: 1,
    project: projectInfo,
    tags: []
  };

  Object.keys(presets).forEach(id => {
    let preset = presets[id];
    if (id.startsWith('@')) return;

    const everyTag: Record<string, Set<string>> = {};
    for (const group of [preset.tags, preset.addTags, preset.removeTags]) {
      for (const key in group) {
          everyTag[key] ||= new Set();
          everyTag[key].add(preset.tags[key]);
      }
    }

    for (const key in everyTag) {
      for (const value of everyTag[key]) {
        let tag: TaginfoTag = { key };
        if (value !== '*') tag.value = value;


        let name = tstrings.presets[id].name;
        if (!name && isReference(preset.name)) {
          name = tstrings.presets[preset.name.slice(1, -1)].name;
        }
        let legacy = (preset.searchable === false) ? ' (unsearchable)' : '';
        tag._description = [ `🄿 ${name}${legacy}` ];

        if (preset.geometry) {
          setObjectType(tag, preset);
        }

        // add icon
        if (preset.icon?.startsWith('maki-')) {
          tag.icon_url = 'https://cdn.jsdelivr.net/gh/mapbox/maki/icons/' +
            preset.icon.replace(/^maki-/, '') + '.svg';
        } else if (preset.icon?.startsWith('temaki-')) {
          tag.icon_url = 'https://cdn.jsdelivr.net/gh/rapideditor/temaki/icons/' +
            preset.icon.replace(/^temaki-/, '') + '.svg';
        } else if (preset.icon && /^fa[srb]-/.test(preset.icon)) {
          tag.icon_url = 'https://cdn.jsdelivr.net/gh/openstreetmap/iD@develop/svg/fontawesome/' +
            preset.icon + '.svg';
        } else if (preset.icon?.startsWith('roentgen-')) {
          tag.icon_url = 'https://cdn.jsdelivr.net/gh/enzet/Roentgen@main/icons/' +
            preset.icon.replace(/^roentgen-/, '') + '.svg';
        } else if (preset.icon?.startsWith('pinhead-')) {
          tag.icon_url = 'https://pinhead.ink/latest/' +
            preset.icon.replace(/^pinhead-/, '') + '.svg';
        } else if (preset.icon?.startsWith('iD-')) {
          tag.icon_url = 'https://cdn.jsdelivr.net/gh/openstreetmap/iD@develop/svg/iD-sprite/presets/' +
            preset.icon.replace(/^iD-/, '') + '.svg';
        }

        coalesceTags(taginfo, tag);
      }
    }
  });

  Object.keys(fields).forEach(id => {
    const field = fields[id];
    const keys = field.keys || (field.key && [field.key]) || [];
    const isRadio = (field.type === 'radio' || field.type === 'structureRadio');

    if (field.type === 'directionalCombo') {
      // for directionalCombo, only :left and :right are included in field.keys,
      // so we need to explicitly add the two other possibilities.
      const base = field.key!.replace(/:both$/, '');
      keys.push(base, `${base}:both`);
    }

    keys.forEach(key => {
      let tag: TaginfoTag = { key: key };

      let label = tstrings.fields[id].label;
      if (!label && field.label && isReference(field.label)) {
        label = tstrings.fields[field.label.slice(1, -1)].label;
      }
      tag._description = [ `🄵 ${label}` ];

      if (areAnyFreeFormTagsAcceptedInField(field)) {
        coalesceTags(taginfo, tag);
      }
      let yesTag = { ...tag, value: 'yes', _description: [`🄵🅅 ${label}: yes`] };
      let noTag  = { ...tag, value: 'no', _description: [`🄵🅅 ${label}: no`] };
      if (field.type === 'defaultCheck') {
        coalesceTags(taginfo, yesTag);
      }
      if (field.type === 'check' || field.type === 'onewayCheck') {
        coalesceTags(taginfo, yesTag);
        coalesceTags(taginfo, noTag);
      }

      if (field.options && !isRadio && field.type !== 'manyCombo') {
        field.options.forEach(value => {
          if (value === 'undefined' || value === '*' || value === '') return;
          let tag: TaginfoTag;
          if (field.type === 'multiCombo') {
            tag = { key: key + value };
          } else {
            tag = { key: key, value: value };
          }
          let valueLabel = tstrings.fields[id].options?.[value];

          if (valueLabel && typeof valueLabel === 'string') {
            tag._description = [ `🄵🅅 ${label}: ${valueLabel}` ];
          } else {
            tag._description = [ `🄵🅅 ${label}: \`${value}\`` ];
          }
          coalesceTags(taginfo, tag);
        });
      }
    });
  });

  if (!deprecated) deprecated = [];
  deprecated.forEach(elem => {
    let old = elem.old;
    let oldKeys = Object.keys(old);
    if (oldKeys.length === 1) {
      let oldKey = oldKeys[0];
      let tag: TaginfoTag = { key: oldKey };

      let oldValue = old[oldKey];
      if (oldValue !== '*') tag.value = oldValue;
      let replacementStrings = [];
      for (let replaceKey in elem.replace) {
        let replaceValue = elem.replace[replaceKey];
        if (replaceValue === '$1') replaceValue = '*';
        replacementStrings.push(`${replaceKey}=${replaceValue}`);
      }
      let description = '🄳 (deprecated tag)';
      if (replacementStrings.length > 0) {
        description += ' ➜ ' + replacementStrings.join(' + ');
      }
      tag._description = [description];
      coalesceTags(taginfo, tag);
    }
  });

  if (!discarded) discarded = {};
  for (const key in discarded) {
    let description = '🄳🄳 (discarded tag)';
    if (discarded[key] === true) {
      // key=*
      let tag = { key, _description: [description] };
      coalesceTags(taginfo, tag);
    } else {
      // key=value
      for (const value in discarded[key]) {
        const tag = { key, value, _description: [description] };
        coalesceTags(taginfo, tag);
      }
    }
  }

  taginfo.tags.forEach(elem => {
    if (elem._description) {
      elem.description = elem._description.join(', ');
      delete elem._description;
    }
  });

  function areAnyFreeFormTagsAcceptedInField(field: Field) {
    if (['multiCombo', 'manyCombo', 'check', 'defaultCheck', 'onewayCheck'].includes(field.type)) {
      return false;
    }
    if (field.autoSuggestions === false && field.customValues === false) {
      return false;
    }
    return true;
  }

  function coalesceTags(taginfo: TaginfoSchema, tag: TaginfoTag) {
    if (!tag.key) return;

    let currentTaginfoEntries = taginfo.tags
      .filter(t => (t.key === tag.key && t.value === tag.value));

    if (currentTaginfoEntries.length === 0) {
      taginfo.tags.push(tag);
      return;
    }

    if (!tag._description) return;

    if (!currentTaginfoEntries[0]._description) {
      currentTaginfoEntries[0]._description = tag._description;
      return;
    }

    let isNewDescription = currentTaginfoEntries[0]._description
      .indexOf(tag._description[0]) === -1;

    if (isNewDescription) {
      currentTaginfoEntries[0]._description!.push(tag._description[0]);
    }
  }


  function setObjectType(tag: TaginfoTag, input: Field | Preset) {
    if (!input.geometry) return;

    tag.object_types = [];
    const mapping: Record<Geometry, Taginfo.ObjectType> = {
      'point'    : 'node',
      'vertex'   : 'node',
      'line'     : 'way',
      'relation' : 'relation',
      'area'     : 'area'
    };

    input.geometry.forEach(geom => {
      if (tag.object_types!.indexOf(mapping[geom]) === -1) {
        tag.object_types!.push(mapping[geom]);
      }
    });
  }

  return taginfo;
}

function generateIconsList(presets: AllPresets, fields: AllFields, categories: AllCategories) {
  const icons: Record<string, true> = {};
  [
    ...Object.values(presets).map(p => p.icon).filter(Boolean),
    ...Object.values(categories).map(c => c.icon).filter(Boolean),
    ...Object.values(fields).flatMap(f => Object.values(f.icons || {}))
  ].forEach(icon => icons[icon!] = true);
  return Object.keys(icons).sort();
}

const toPascalCase = (string: string) => string.replace(/(_.|^.)/g, match => match.at(-1)!.toUpperCase());

const createTypeIdentifier = (string: string) => toPascalCase(toSafeIdentifier(string));


async function generateTypeDefs(distDir: string) {
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
  const inputFolder = path.join(__dirname, '../../schemas');

  /**
   * Some generated files use plural names because they
   * export an object, e.g. `Fields = Record<string, Field>`
   */
  const KEY_MAP: Record<string, string> = {
    field: 'fields',
    preset: 'presets',
    preset_category: 'preset_categories',
  };
  const fileNames = fs.globSync(path.join(inputFolder, '/**/*.json'));

  async function processFile(fileName: string) {
    const key = path.parse(fileName).name;
    const pluralKey = KEY_MAP[key];

    const mainExport = createTypeIdentifier(pluralKey || key);

    const output = [''];

    if (pluralKey) {
      output.push(
        `export interface ${createTypeIdentifier(pluralKey)} {`,
        `  [id: string]: ${createTypeIdentifier(key)}`,
        '}'
      );
    }

    output.push(
      `declare const json: ${mainExport};`,
      'export default json;'
    );

    const fileContent = JSON.parse(await fs.promises.readFile(fileName, 'utf8'));
    if (key === 'field') delete fileContent.anyOf;

    const tsFile = await compile(fileContent, mainExport, {
      additionalProperties: false,
      ignoreMinAndMaxItems: true,
      cwd: path.join(__dirname, '../../schemas'),

      // ensure that the default export uses a consistent name
      customName: (schema, fallback) =>
        schema.$schema && schema.$id
          ? createTypeIdentifier(path.parse(schema.$id).name)
          : (schema.$id || schema.title || fallback || ''),
    });


    output.unshift(tsFile);
    output.push('');
    await fs.promises.writeFile(
      path.join(distDir, `${pluralKey || key}.d.json.ts`),
      output.join('\n'),
    );
  }
  await Promise.all(fileNames.map(processFile));

  // finally, create the index file which re-exports everything
  // as named types.
  const indexFile = fileNames
    .map((fileName) => {
      const key = path.parse(fileName).name;
      return `export type * from './${KEY_MAP[key] || key}.d.json.ts';`;
    })
    .join('\n');

  await fs.promises.writeFile(
    path.join(distDir, 'index.d.ts'),
    indexFile + '\n',
  );
}

function validateCategoryPresets(categories: AllCategories, presets: AllPresets) {
  Object.keys(categories).forEach(id => {
    const category = categories[id];
    if (!category.members) return;
    category.members.forEach(preset => {
      if (presets[preset] === undefined) {
        process.stderr.write('Unknown preset: ' + preset + ' in category ' + category.name + '\n');
        process.stdout.write('\n');
        process.exit(1);
      }
    });
  });
}

function validatePresetFields(presets: AllPresets, fields: AllFields) {
  const betweenBracketsRegex = /([^{]*?)(?=\})/;
  const maxFieldsBeforeError = 10;

  let usedFieldIDs = new Set();

  for (let presetID in presets) {
    let preset = presets[presetID];

    if (preset.replacement) {
      let replacementPreset = presets[preset.replacement];
      let p1geometry = preset.geometry.slice().sort.toString();
      let p2geometry = replacementPreset.geometry.slice().sort.toString();
      if (replacementPreset === undefined) {
        process.stderr.write('Unknown preset "' + preset.replacement + '" referenced as replacement of preset "' + presetID + '" (' + preset.name + ')\n');
        process.stdout.write('\n');
        process.exit(1);
      } else if (p1geometry !== p2geometry) {
        process.stderr.write('The preset "' + presetID + '" has different geometry than its replacement preset, "' + preset.replacement + '". They must match for tag upgrades to work.\n');
        process.stdout.write('\n');
        process.exit(1);
      }
    }

    // the keys for properties that contain arrays of field ids
    let fieldKeys = ['fields', 'moreFields'] as const;
    for (let fieldsKeyIndex in fieldKeys) {
      let fieldsKey = fieldKeys[fieldsKeyIndex];
      if (!preset[fieldsKey]) continue; // no fields are referenced, okay

      for (let fieldIndex in preset[fieldsKey]) {
        let fieldID = preset[fieldsKey][fieldIndex];
        usedFieldIDs.add(fieldID);
        let field = fields[fieldID];
        if (field) {
          if (field.geometry) {
            let sharedGeometry = field.geometry.filter(value => preset.geometry.includes(value));
            if (!sharedGeometry.length) {
              process.stderr.write('The preset "' + presetID + '" (' + preset.name + ') will never display the field "' + fieldID + '" since they don\'t share geometry types.\n');
              process.stdout.write('\n');
              process.exit(1);
            }
          }

        } else {
          // no field found with this ID...

          let regexResult = betweenBracketsRegex.exec(fieldID);
          if (regexResult) {
            let foreignPresetID = regexResult[0];
            if (presets[foreignPresetID] === undefined) {
              process.stderr.write('Unknown preset "' + foreignPresetID + '" referenced in "' + fieldsKey + '" array of preset "' + presetID + '" (' + preset.name + ')\n');
              process.stdout.write('\n');
              process.exit(1);
            }
          } else {
            process.stderr.write('Unknown preset field "' + fieldID + '" in "' + fieldsKey + '" array of preset "' + presetID + '" (' + preset.name + ')\n');
            process.stdout.write('\n');
            process.exit(1);
          }
        }


      }
    }

    if (preset.fields) {
      // since `moreFields` is available, check that `fields` doesn't get too cluttered
      let fieldCount = preset.fields.length;

      if (fieldCount > maxFieldsBeforeError) {
        // Fields with `prerequisiteTag` or `geometry` may not always be shown,
        // so don't count them against the limits.
        const alwaysShownFields = preset.fields.filter(fieldID => {
          if (fields[fieldID]?.prerequisiteTag || fields[fieldID]?.geometry) return false;
          return true;
        });
        fieldCount = alwaysShownFields.length;
      }
      if (fieldCount > maxFieldsBeforeError) {
        process.stderr.write(fieldCount + ' values in "fields" of "' + preset.name + '" (' + presetID + '). Limit: ' + maxFieldsBeforeError + '. Please move lower-priority fields to "moreFields".\n');
        process.stdout.write('\n');
        process.exit(1);
      }
    }
  }

  for (let fieldID in fields) {
    if (!usedFieldIDs.has(fieldID) &&
        fields[fieldID].universal !== true &&
        (fields[fieldID].usage || 'preset') === 'preset') {
      process.stdout.write('Field "' + fields[fieldID].label + '" (' + fieldID + ') isn\'t used by any presets.\n');
    }
  }
}

function validateTerms(terms: string[] | undefined, where: string) {
  if (!terms) return;

  const expectedTerms = terms.map(term => term.toLowerCase().trim()).sort();
  if (terms.every((term, index) => expectedTerms[index] === term)) return;

  process.stderr.write(`Expected terms in ${where} to be lowercase and sorted alphabetically.`);
  process.stdout.write('\n');
  process.stdout.write('expected terms\n\n');
  process.stdout.write(JSON.stringify(expectedTerms, null, 2));
  process.stdout.write('\n\ndiffer from actual ones\n\n');
  process.stdout.write(`${terms}`);
  process.stdout.write('\n');
  process.exit(1);
}

function validateDefaults(defaults: PresetDefaults, categories: AllCategories, presets: AllPresets) {
  Object.keys(defaults).forEach(name => {
    const members = defaults[name as keyof PresetDefaults];
    members.forEach(id => {
      if (!presets[id] && !categories[id]) {
        process.stderr.write(`Unknown category or preset: ${id} in default ${name}\n`);
        process.stdout.write('\n');
        process.exit(1);
      }
    });
  });
}

function translationsToYAML(translations: SourceStrings) {
  // comment keys start with '#' and should sort immediately before their related key.
  function commentFirst(a: string, b: string) {
    if (a === '#' + b) return -1;
    if (b === '#' + a) return 1;
    if (a[0] !== b[0]) {
      if (a[0] === '#') a = a.substr(1);
      if (b[0] === '#') b = b.substr(1);
    }
    return (a > b ? 1 : a < b ? -1 : 0);
  }

  return dumpYaml(translations, { sortKeys: commentFirst, lineWidth: -1 })
    .replace(/'?#.*?'?:/g, '#');
}


function minifyJSON(inPath: string, outPath: string) {
  return new Promise<void>((resolve, reject) => {
    if (!fs.existsSync(inPath)) {
      resolve();
      return;
    }
    fs.readFile(inPath, 'utf8', (err, data) => {
      if (err) return reject(err);

      const minified = JSON.stringify(JSON.parse(data));
      fs.writeFile(outPath, minified, (err) => {
        if (err) return reject(err);
        resolve();
      });

    });
  });
}

export {
  buildDev,
  buildDist,
  validateData as validate
};
