import fs from 'fs';
import path from 'path';

import type { z, ZodError } from 'zod';

import { ACCESSION_FIELD, FASTA_IDS_FIELD, SUBMISSION_ID_INPUT_FIELD } from './settings.ts';
import {
    type InputField,
    type InstanceConfig,
    type MetadataType,
    type Schema,
    type SequenceFlaggingConfig,
    type WebsiteConfig,
    instanceConfig,
    websiteConfig,
} from './types/config.ts';
import { type ReferenceGenomesInfo } from './types/referencesGenomes.ts';
import { runtimeConfig, type RuntimeConfig, type ServiceUrls } from './types/runtimeConfig.ts';
import { toReferenceGenomes } from './utils/sequenceTypeHelpers.ts';

let _config: WebsiteConfig | null = null;
let _runtimeConfig: RuntimeConfig | null = null;

function getConfigDir(): string {
    const configDir = import.meta.env.CONFIG_DIR;
    if (typeof configDir !== 'string' || configDir === '') {
        throw new Error(`CONFIG_DIR environment variable was not set during build time, is '${configDir}'`);
    }
    return configDir;
}

export function validateWebsiteConfig(config: WebsiteConfig): Error[] {
    const errors: Error[] = [];
    Array.from(Object.entries(config.organisms).values()).forEach(([organism, schema]) => {
        if (schema.schema.metadataTemplate !== undefined) {
            schema.schema.metadataTemplate.forEach((fieldName) => {
                if (schema.schema.inputFields.find((inputField) => inputField.name === fieldName) === undefined) {
                    errors.push(
                        Error(
                            `Error in ${organism}.schema.metadataTemplate: ` +
                                `${fieldName} is not defined in the inputFields.`,
                        ),
                    );
                }
            });
        }

        const referenceIdentifierField = schema.schema.referenceIdentifierField;
        const hasMultipleReferences = schema.referenceGenomes
            ?.map((segment) => segment.references.length > 1)
            .some((v) => v);
        if (referenceIdentifierField == undefined && hasMultipleReferences) {
            errors.push(
                new Error(
                    `Organism '${organism}' has multiple references but referenceIdentifierField is not defined in the schema.`,
                ),
            );
        }
    });
    return errors;
}

export function getWebsiteConfig(): WebsiteConfig {
    if (_config === null) {
        const config = readWebsiteConfigFromDir(getConfigDir());
        const validationErrors = validateWebsiteConfig(config);
        if (validationErrors.length > 0) {
            throw new AggregateError(validationErrors, 'There were validation errors in the website_config.json');
        }
        _config = config;
    }
    return _config;
}

export function readWebsiteConfigFromDir(configDir: string): WebsiteConfig {
    const config = readTypedConfigFile(configDir, 'website_config.json', websiteConfig);
    const organismsDir = path.join(configDir, 'organisms');

    let organismFiles: string[];
    try {
        const stat = fs.statSync(organismsDir);
        if (!stat.isDirectory()) {
            return config;
        }
        organismFiles = fs.readdirSync(organismsDir);
    } catch (e: unknown) {
        if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
            return config;
        }
        throw e;
    }

    // Each organisms/*.json holds a single organism's config; it is merged into (and overrides any
    // same-key entry in) the base website_config.json. In production the Helm template always strips
    // organisms from the base config, so these split files are the sole source of organism configs.
    for (const fileName of organismFiles.filter((fileName) => fileName.endsWith('.json')).sort()) {
        config.organisms[path.basename(fileName, '.json')] = readTypedConfigFile(
            configDir,
            path.join('organisms', fileName),
            instanceConfig,
        );
    }
    return config;
}

export function getContactConfig(websiteConfig: WebsiteConfig) {
    return {
        gitHubIssuesUrl: websiteConfig.gitHubIssuesUrl,
        issuesEmail: websiteConfig.issuesEmail,
    };
}

/**
 * If sequence flagging is configured, returns a report URL to create a GitHub issue for the given
 * organism and accession version.
 * Returns undefined if sequence flagging is not configured.
 */
export function getGitHubReportUrl(
    sequenceFlaggingConfig: SequenceFlaggingConfig | undefined,
    organism: string,
    accessionVersion: string,
): string | undefined {
    if (sequenceFlaggingConfig === undefined) return undefined;

    const ghConf = sequenceFlaggingConfig.github;
    const url = new URL(`/${ghConf.organization}/${ghConf.repository}/issues/new`, 'https://github.com');
    if (ghConf.issueTemplate) {
        url.searchParams.append('template', ghConf.issueTemplate);
    }
    url.searchParams.append('title', `[${organism} - ${accessionVersion}]`);
    return url.toString();
}

export function safeGetWebsiteConfig(): WebsiteConfig | null {
    try {
        return getWebsiteConfig();
    } catch (_) {
        return null;
    }
}

export function getMetadataDisplayNames(organism: string): Map<string, string> {
    return new Map(
        getWebsiteConfig().organisms[organism].schema.metadata.map(({ name, displayName }) => [
            name,
            displayName ?? name,
        ]),
    );
}

export type Organism = {
    key: string;
    displayName: string;
    image?: string;
};

export function configuredOrganismsFromConfig(config: WebsiteConfig): Organism[] {
    return Object.entries(config.organisms)
        .map(([key, instance]) => ({
            key,
            displayName: instance.schema.organismName,
            image: instance.schema.image,
        }))
        .sort((a, b) => a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' }));
}

export function getConfiguredOrganisms() {
    return configuredOrganismsFromConfig(getWebsiteConfig());
}

function getConfig(organism: string): InstanceConfig {
    const websiteConfig = getWebsiteConfig();
    if (!(organism in websiteConfig.organisms)) {
        throw new Error(`No configuration for organism ${organism}`);
    }
    return websiteConfig.organisms[organism];
}

export function outputFilesEnabled(organism: string): boolean {
    return (getConfig(organism).schema.files ?? []).length > 0;
}

export function getSchema(organism: string): Schema {
    return getConfig(organism).schema;
}

export function getMetadataTemplateFields(
    organism: string,
    action: 'submit' | 'revise',
): Map<string, string | undefined> {
    const schema = getConfig(organism).schema;
    const baseFields: string[] = schema.metadataTemplate ?? schema.inputFields.map((field) => field.name);
    const submissionIdInputFields = getSubmissionIdInputFields(schema).map((field) => field.name);
    const extraFields = action === 'submit' ? submissionIdInputFields : [ACCESSION_FIELD, ...submissionIdInputFields];
    const allFields = [...extraFields, ...baseFields];
    const fieldsToDisplaynames = new Map<string, string | undefined>(
        allFields.map((field) => [field, schema.metadata.find((metadata) => metadata.name === field)?.displayName]),
    );
    return fieldsToDisplaynames;
}

/**
 * An {@link InputField} as it should appear in the downloadable submission template, tagged with
 * whether it is one of the fields enabled by default (a "template field") and with the field's
 * metadata `type` (used e.g. to format date columns). Default-enabled fields are ordered before the
 * remaining, opt-in fields.
 */
export type TemplateInputField = InputField & { isTemplateField: boolean; metadataType?: MetadataType };

/**
 * Returns every submittable input field for the template download, in column order:
 * submission-detail fields first, then the default-enabled "template" fields
 * (`schema.metadataTemplate`, or all input fields if unset), then the remaining opt-in fields.
 * Fields enabled by default are tagged with `isTemplateField: true`.
 */
export function getOrderedTemplateInputFields(organism: string, action: 'submit' | 'revise'): TemplateInputField[] {
    const schema = getConfig(organism).schema;

    const submissionIdInputFields = getSubmissionIdInputFields(schema);
    const accessionFields = action === 'revise' ? [getAccessionInputField()] : [];
    const requiredInternalFields = [...accessionFields, ...submissionIdInputFields];

    const requiredInternalFieldNames = new Set(requiredInternalFields.map((field) => field.name));
    const nonInternalInputFields = schema.inputFields.filter((field) => !requiredInternalFieldNames.has(field.name));

    const metadataTypeByName = new Map(schema.metadata.map((entry) => [entry.name, entry.type] as const));
    const decorate = (field: InputField, isTemplateField: boolean): TemplateInputField => ({
        ...field,
        isTemplateField,
        metadataType: metadataTypeByName.get(field.name),
    });

    // Required internal fields (submissionId/fastaIds, plus accession on revise) always lead the columns.
    const orderedRequiredFields = requiredInternalFields.map((field) => decorate(field, true));

    // Without an explicit template, every input field is enabled by default.
    if (schema.metadataTemplate === undefined) {
        return [...orderedRequiredFields, ...nonInternalInputFields.map((field) => decorate(field, true))];
    }

    // With a template, the listed fields are enabled (in template order) and the rest become opt-in.
    const fieldsByName = new Map(nonInternalInputFields.map((field) => [field.name, field]));
    const templateFieldNameSet = new Set(schema.metadataTemplate);
    const templateFields = schema.metadataTemplate
        .map((name) => fieldsByName.get(name))
        .filter((field): field is InputField => field !== undefined);
    const restFields = nonInternalInputFields.filter((field) => !templateFieldNameSet.has(field.name));

    return [
        ...orderedRequiredFields,
        ...templateFields.map((field) => decorate(field, true)),
        ...restFields.map((field) => decorate(field, false)),
    ];
}

function getAccessionInputField(): InputField {
    const accessionPrefix = getWebsiteConfig().accessionPrefix;
    const instanceName = getWebsiteConfig().name;
    return {
        name: ACCESSION_FIELD,
        displayName: 'Accession',
        definition: `The ${instanceName} accession (without version) of the sequence you would like to revise.`,
        example: `${accessionPrefix}000P97Y`,
        noEdit: true,
        required: true,
    };
}

export function getSubmissionIdInputFields(schema: Schema): InputField[] {
    const idField = schema.inputFields.find((f) => f.name === SUBMISSION_ID_INPUT_FIELD);
    if (!idField) throw new Error(`Missing required '${SUBMISSION_ID_INPUT_FIELD}' input field in schema`);
    const fastaIdsField = schema.inputFields.find((f) => f.name === FASTA_IDS_FIELD);

    return fastaIdsField ? [idField, fastaIdsField] : [idField];
}

export function getGroupedInputFields(
    organism: string,
    action: 'submit' | 'revise',
    excludeDuplicates: boolean = false,
): Map<string, InputField[]> {
    const schema = getConfig(organism).schema;
    const submissionIdInputFields = getSubmissionIdInputFields(schema);
    const submissionIdFieldNames = new Set(submissionIdInputFields.map((f) => f.name));
    const nonIdInputFields = schema.inputFields.filter((f) => !submissionIdFieldNames.has(f.name));

    const allFields = [
        ...submissionIdInputFields,
        ...(action === 'submit' ? [] : [getAccessionInputField()]),
        ...nonIdInputFields,
    ];
    const requiredFields = allFields.filter((meta) => meta.required);
    const desiredFields = allFields.filter((meta) => meta.desired);

    const groups = new Map<string, InputField[]>();
    groups.set('Required fields', requiredFields);
    groups.set('Desired fields', desiredFields);
    if (!excludeDuplicates) groups.set('Submission details', submissionIdInputFields);
    const fieldAlreadyAdded = (fieldName: string) =>
        Array.from(groups.values())
            .flatMap((fields) => fields.map((f) => f.name))
            .some((name) => name === fieldName);

    nonIdInputFields.forEach((field) => {
        const metadataEntry = schema.metadata.find((meta) => meta.name === field.name);
        const header = metadataEntry?.header ?? 'Uncategorized';

        if (!groups.has(header)) {
            groups.set(header, []);
        }

        // Optionally remove duplicates
        if (excludeDuplicates && fieldAlreadyAdded(field.name)) {
            return;
        }

        groups.get(header)!.push({ ...field });
    });

    return groups;
}

export function getRuntimeConfig(): RuntimeConfig {
    _runtimeConfig ??= readTypedConfigFile(getConfigDir(), 'runtime_config.json', runtimeConfig);
    return _runtimeConfig;
}

export function getLapisUrl(serviceConfig: ServiceUrls, organism: string): string {
    if (!(organism in serviceConfig.lapisUrls)) {
        throw new Error(`No lapis url configured for organism ${organism}`);
    }
    return serviceConfig.lapisUrls[organism];
}

export function getReferenceGenomes(organism: string): ReferenceGenomesInfo {
    return toReferenceGenomes(getConfig(organism).referenceGenomes);
}

export function seqSetsAreEnabled() {
    return getWebsiteConfig().enableSeqSets;
}

export function dataUseTermsAreEnabled() {
    return getWebsiteConfig().enableDataUseTerms;
}

export function getDataUseTermsAgreementHTML() {
    return getWebsiteConfig().dataUseTermsAgreementHTML;
}

function readTypedConfigFile<Schema extends z.ZodTypeAny>(
    configDir: string,
    fileName: string,
    schema: Schema,
): z.infer<Schema> {
    const configFilePath = path.join(configDir, fileName);
    const json = JSON.parse(fs.readFileSync(configFilePath, 'utf8'));
    try {
        return schema.parse(json) as z.infer<Schema>;
    } catch (e) {
        const zodError = e as ZodError;
        throw new Error(`Type error reading ${configFilePath}: ${zodError.message}`);
    }
}
