import { ensureDir, copy } from 'fs-extra';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import fs, { promises } from 'node:fs';
import axios from 'axios';
import fastGlob from 'fast-glob';
import zod from 'zod';
import { generateMD5Hash } from '@adguard/tsurlfilter/cli';
import { Filter, RuleSet, RulesHashMap, IndexedNetworkRuleWithHash, METADATA_RULESET_ID, MetadataRuleSet } from '@adguard/tsurlfilter/es/declarative-converter';
import { getRuleSetId, isSafeRule, getRuleSetPath, extractRuleSetId, RULESET_FILE_EXT } from '@adguard/tsurlfilter/es/declarative-converter-utils';
import { RuleParser } from '@adguard/agtree/parser';
import { FilterListPreprocessor } from '@adguard/tsurlfilter';

const FILTERS_SERVER_URL = 'https://filters.adtidy.org/extension/chromium-mv3';
const BASE_DIR = './dist';
const COMMON_FILTERS_DIR = `${BASE_DIR}/filters`;
const FILTERS_DIR = COMMON_FILTERS_DIR;
const FILTERS_URL = `${FILTERS_SERVER_URL}/filters`;
// IMPORTANT: extensions '.js' used for correct work of Cloudflare cache, but
// real format of these files is JSON.
// See AG-1901 for details.
const REMOTE_METADATA_FILE_NAME = 'filters.js';
const REMOTE_I18N_METADATA_FILE_NAME = 'filters_i18n.js';
// But locally we prefer to use '.json' extension.
const LOCAL_METADATA_FILE_NAME = 'filters.json';
const LOCAL_I18N_METADATA_FILE_NAME = 'filters_i18n.json';
const FILTERS_METADATA_URL = `${FILTERS_SERVER_URL}/${REMOTE_METADATA_FILE_NAME}`;
const FILTERS_METADATA_I18N_URL = `${FILTERS_SERVER_URL}/${REMOTE_I18N_METADATA_FILE_NAME}`;
const QUICK_FIXES_FILTER_ID = 24;

/**
 * Download metadata from {@link FILTERS_METADATA_URL}.
 *
 * @param pathToSave Path to save metadata file.
 *
 * @returns Filter metadata.
 */ async function downloadMetadata(pathToSave) {
    console.info(`Download ${FILTERS_METADATA_URL}...`);
    const { data } = await axios.get(FILTERS_METADATA_URL, {
        responseType: 'json'
    });
    if (pathToSave) {
        await fs.promises.writeFile(pathToSave, JSON.stringify(data, null, '\t'));
        console.info(`Download ${FILTERS_METADATA_URL} done, saved to ${pathToSave}`);
    }
    return data;
}
/**
 * Download i18n metadata from {@link FILTERS_METADATA_I18N_URL}.
 *
 * @param pathToSave Path to save i18n metadata file.
 *
 * @returns I18n Filter metadata.
 */ async function downloadI18nMetadata(pathToSave) {
    console.info(`Download ${FILTERS_METADATA_I18N_URL}...`);
    const { data } = await axios.get(FILTERS_METADATA_I18N_URL);
    await fs.promises.writeFile(pathToSave, JSON.stringify(data, null, '\t'));
    console.info(`Download ${FILTERS_METADATA_I18N_URL} done, saved to ${pathToSave}`);
    return data;
}

/**
 * Gets {@link FilterDTO} array from filter metadata.
 * AdGuard Quick Fixes filter is excluded from downloading and conversion.
 *
 * @param metadata Filters metadata downloaded from `FILTERS_METADATA_URL`.
 *
 * @returns Array of filter data.
 */ const getUrlsOfFiltersResources = async (metadata)=>{
    return metadata.filters// We exclude this filter from downloading and conversion,
    // because it should be loaded from the server on the client and applied
    // dynamically.
    .filter(({ filterId })=>filterId !== QUICK_FIXES_FILTER_ID).map(({ filterId })=>({
            id: filterId,
            url: `${FILTERS_URL}/${filterId}.txt`,
            file: `filter_${filterId}.txt`
        }));
};
/**
 * Downloads filter from the server and saves it to the specified directory.
 *
 * @param filter Filter data transfer object.
 * @param filtersDir Filters directory.
 */ const downloadFilter = async (filter, filtersDir)=>{
    console.info(`Download ${filter.url}...`);
    const response = await axios.get(filter.url, {
        responseType: 'text'
    });
    const pathToSave = path.join(filtersDir, filter.file);
    await fs.promises.writeFile(pathToSave, response.data);
    console.info(`Download ${filter.url} done, saved to ${pathToSave}`);
};
/**
 * Downloads filters from the server and saves them to the specified directory.
 *
 * @param filtersDir Directory to save filters to. Defaults to `FILTERS_DIR`.
 *
 * @returns Promise that resolves the filters metadata.
 */ const startDownload = async (filtersDir = FILTERS_DIR)=>{
    console.log(`Starting filters download to ${filtersDir}...`);
    await ensureDir(filtersDir);
    console.log(`Downloading filters metadata files...`);
    const metadataPathToSave = path.join(filtersDir, LOCAL_METADATA_FILE_NAME);
    const metadata = await downloadMetadata(metadataPathToSave);
    const i18nMetadataPathToSave = path.join(filtersDir, LOCAL_I18N_METADATA_FILE_NAME);
    await downloadI18nMetadata(i18nMetadataPathToSave);
    console.log(`Downloading filters resources to ${filtersDir}...`);
    const filters = await getUrlsOfFiltersResources(metadata);
    await Promise.all(filters.map((filter)=>downloadFilter(filter, filtersDir)));
};

const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
 * Api for loading assets.
 *
 */ class AssetsLoader {
    /**
     * Download rulesets or filters to {@link dest} path. If {@link options.rawFilters}
     * is set to `true`, it will only download raw filters from the server,
     * otherwise it will copy rulesets from the local directory.
     *
     * @param dest Path to download assets.
     * @param options Options for loading assets.
     *
     * @returns Promise that resolves when assets are downloaded.
     */ async load(dest, options) {
        const to = path.resolve(process.cwd(), dest);
        if (options?.latestFilters) {
            await startDownload(to);
            return;
        }
        const src = path.resolve(__dirname, '../filters');
        console.log(`Copying rulesets from ${src} to ${to}`);
        await copy(src, to);
        console.log(`Copying rulesets from ${src} to ${to} done.`);
    }
}

/**
 * Api for injecting rulesets into manifest.
 *
 * @see {@link RulesetsInjectorInterface}
 */ class RulesetsInjector {
    /** @inheritdoc */ applyRulesets(generateRulesetPath, manifest, filterNames, options) {
        if (!manifest.declarative_net_request) {
            manifest.declarative_net_request = {};
        }
        if (!manifest.declarative_net_request.rule_resources) {
            manifest.declarative_net_request.rule_resources = [];
        }
        const ruleResources = manifest.declarative_net_request.rule_resources;
        for (const filterName of filterNames){
            const rulesetIdMatch = filterName.match(/\d+/);
            if (!rulesetIdMatch) {
                continue;
            }
            const matchedRulesetId = rulesetIdMatch[0];
            if (Array.isArray(options?.ids) && options.ids.length > 0 && !options.ids.includes(matchedRulesetId)) {
                continue;
            }
            const rulesetId = `${options?.rulesetPrefix ?? RulesetsInjector.DEFAULT_RULESET_PREFIX}${matchedRulesetId}`;
            const isEnabled = Array.isArray(options?.enable) && options.enable.length > 0 && options.enable.includes(matchedRulesetId);
            const ruleset = {
                id: rulesetId,
                enabled: isEnabled,
                path: generateRulesetPath(rulesetId)
            };
            const existingRulesetIndex = ruleResources.findIndex(({ id })=>id === rulesetId);
            if (existingRulesetIndex !== -1) {
                if (options?.forceUpdate) {
                    ruleResources[existingRulesetIndex] = ruleset;
                } else {
                    throw new Error(`Duplicate ruleset ID: ${rulesetId}`);
                }
            } else {
                ruleResources.push(ruleset);
            }
        }
        return manifest;
    }
}
/**
     * Default prefix for ruleset id.
     */ RulesetsInjector.DEFAULT_RULESET_PREFIX = 'ruleset_';

/**
 * Creates manifest schema.
 *
 * @returns Manifest runtime validation schema.
 */ function createManifestSchema() {
    return zod.object({
        declarative_net_request: zod.object({
            rule_resources: zod.array(zod.object({
                id: zod.string(),
                enabled: zod.boolean()
            }).passthrough()).optional()
        }).optional()
    }).passthrough();
}
/**
 * Api for parsing manifest.
 *
 * @see {@link ManifestParserInterface}
 */ class ManifestParser {
    /** @inheritdoc */ parse(input) {
        const json = JSON.parse(input);
        return this.schema.parse(json);
    }
    constructor(){
        /**
     * Manifest runtime validation schema.
     */ this.schema = createManifestSchema();
    }
}

/**
 * Api for loading manifest.
 *
 * @see {@link ManifestLoaderInterface}
 */ class ManifestLoader {
    /** @inheritdoc */ load(path) {
        const data = fs.readFileSync(path, {
            encoding: 'utf-8'
        });
        return this.parser.parse(data);
    }
    /**
     * Create new instance of {@link ManifestLoader}.
     *
     * @param parser {@link ManifestParser}.
     */ constructor(parser = new ManifestParser()){
        this.parser = parser;
    }
}

/**
 * Api for patching manifest.
 */ class ManifestPatcher {
    /**
     * Append rulesets into manifest `declarative_net_request` property.
     * If {@link options.forceUpdate} flag is enabled, overwrite rulesets with existing id, otherwise throw error.
     *
     * @param manifestPath Path to manifest file.
     * @param filtersPath Path to filters directory.
     * @param options Patch options {@link PatchManifestOptions}.
     *
     * @throws Error if manifest already contains ruleset with the specified ids
     * and {@link options.forceUpdate} is disabled or if manifest file or filters directory are not found.
     */ patch(manifestPath, filtersPath, options) {
        const absoluteManifestPath = ManifestPatcher.getAbsolutePath(manifestPath);
        const absoluteFiltersPath = ManifestPatcher.getAbsolutePath(filtersPath);
        const manifest = this.loader.load(absoluteManifestPath);
        const manifestDirPath = path.dirname(absoluteManifestPath);
        const getPath = (rulesetId)=>ManifestPatcher.getRelativeRulesetPath(absoluteFiltersPath, manifestDirPath, rulesetId);
        const filtersMatchGlob = options?.filtersMatch ?? ManifestPatcher.DEFAULT_FILTERS_MATCH_GLOB;
        const filterNames = fastGlob.globSync(filtersMatchGlob, {
            onlyFiles: true,
            cwd: absoluteFiltersPath
        });
        const patchedManifest = this.injector.applyRulesets(getPath, manifest, filterNames, options);
        fs.writeFileSync(absoluteManifestPath, JSON.stringify(patchedManifest, null, 4));
    }
    /**
     * Get relative path for manifest ruleset config.
     *
     * @param filtersDirPath Absolute path to filters directory.
     * @param manifestDirPath Absolute path to manifest directory.
     * @param rulesetName Ruleset Name.
     *
     * @returns Relative path to specified ruleset config.
     */ static getRelativeRulesetPath(filtersDirPath, manifestDirPath, rulesetName) {
        return path.relative(manifestDirPath, `${filtersDirPath}/declarative/${rulesetName}/${rulesetName}.json`);
    }
    /**
     * Gets absolute path for specified file.
     *
     * @param filePath Path to file.
     *
     * @returns Absolute path to file.
     *
     * @throws Error if file is not found.
     */ static getAbsolutePath(filePath) {
        const absoluteFilePath = !path.isAbsolute(filePath) ? path.resolve(process.cwd(), filePath) : filePath;
        if (!fs.existsSync(absoluteFilePath)) {
            throw new Error('File is not found!');
        }
        return absoluteFilePath;
    }
    /**
     * Create new instance of {@link ManifestPatcher}.
     *
     * @param loader {@link ManifestLoaderInterface}.
     * @param injector {@link RulesetsInjectorInterface}.
     */ constructor(loader = new ManifestLoader(), injector = new RulesetsInjector()){
        this.loader = loader;
        this.injector = injector;
    }
}
/**
     * Default glob patter to match filter files.
     */ ManifestPatcher.DEFAULT_FILTERS_MATCH_GLOB = 'filter_+([0-9]).txt';

/**
 * Recursively finds files in a directory that match the provided filter.
 *
 * @param dir The directory to search in.
 * @param filter A filter function to determine if a file should be included.
 *
 * @returns An array of file paths that match the filter.
 */ const findFiles = async (dir, filter)=>{
    const files = await fs.promises.readdir(dir);
    let fileList = [];
    for(let i = 0; i < files.length; i += 1){
        const file = files[i];
        const filePath = path.join(dir, file);
        const stat = await fs.promises.stat(filePath);
        if (stat.isDirectory()) {
            const foundFiles = await findFiles(filePath, filter);
            // Use concat but not push with spread to prevent stack overflow.
            fileList = fileList.concat(foundFiles);
        } else if (filter(filePath)) {
            fileList = fileList.concat([
                filePath
            ]);
        }
    }
    return fileList;
};

/**
 * Loads ruleset and filter.
 *
 * @param rulesetPath Path to the ruleset.
 * @param id String id (e.g., '999').
 *
 * @returns Promise with loaded Ruleset.
 */ async function loadRulesetAndFilter(rulesetPath, id) {
    const rulesetRaw = await promises.readFile(rulesetPath, 'utf8');
    // Extract metadata, lazyMetadata, and rules from ruleset JSON
    const parsedRuleset = JSON.parse(rulesetRaw);
    // First rule is always rule with metadata.
    const metadataRule = parsedRuleset[0];
    const { metadata, lazyMetadata, conversionMap, rawFilterList } = metadataRule.metadata;
    const preprocessedFilterList = FilterListPreprocessor.preprocessLightweight({
        rawFilterList,
        conversionMap
    });
    const filterId = Number(id);
    const filter = new Filter(filterId, {
        getContent: ()=>Promise.resolve(preprocessedFilterList)
    }, true);
    // Prepare Ruleset.deserialize dependencies
    const ruleSetId = getRuleSetId(id);
    const rawData = JSON.stringify(metadata);
    const loadLazyData = async ()=>JSON.stringify(lazyMetadata);
    const loadDeclarativeRules = async ()=>JSON.stringify(parsedRuleset.slice(1));
    const filterList = [
        filter
    ];
    // Deserialize Ruleset as in extension
    const { data: { regexpRulesCount, unsafeRulesCount, rulesCount, ruleSetHashMapRaw, badFilterRulesRaw }, ruleSetContentProvider } = await RuleSet.deserialize(ruleSetId, rawData, loadLazyData, loadDeclarativeRules, filterList);
    const sources = RulesHashMap.deserializeSources(ruleSetHashMapRaw);
    const ruleSetHashMap = new RulesHashMap(sources);
    const badFilterRules = badFilterRulesRaw.map((rawString)=>IndexedNetworkRuleWithHash.createFromNode(0, 0, RuleParser.parse(rawString))).flat();
    const ruleSet = new RuleSet(ruleSetId, rulesCount, unsafeRulesCount, regexpRulesCount, ruleSetContentProvider, badFilterRules, ruleSetHashMap);
    console.log(`Loaded ruleset with ID ${id} from ${rulesetPath}`);
    return ruleSet;
}

/**
 * Scans a folder for ruleset JSON files and loads them.
 *
 * @param dirPath The path to the directory containing ruleset JSON files.
 *
 * @returns A promise that resolves to an array of rulesets.
 */ async function scanFolder(dirPath) {
    await ensureDir(dirPath);
    const rulesetsPaths = await findFiles(dirPath, (filePath)=>filePath.endsWith(RULESET_FILE_EXT));
    const loadTasks = rulesetsPaths.map((rulesetPath)=>{
        const jsonFilePath = rulesetPath;
        const id = extractRuleSetId(jsonFilePath);
        if (id === null) {
            throw new Error(`Failed to extract ruleset ID from path: ${jsonFilePath}.`);
        }
        // Skip ruleset with metadata.
        if (id === METADATA_RULESET_ID) {
            return null;
        }
        return [
            rulesetPath,
            id
        ];
    }).filter((pathWithId)=>pathWithId !== null).map(([rulesetPath, id])=>{
        return loadRulesetAndFilter(rulesetPath, id);
    });
    return Promise.all(loadTasks);
}
/**
 * Updates the metadata ruleset with checksums and saves it to the specified path.
 *
 * @param metadataRuleSetPath The path to the metadata ruleset.
 * @param checksums An object containing checksums for the rulesets,
 * where keys are ruleset IDs and values are checksums.
 * @param prettifyJson Optional parameter to define whether to prettify the JSON output.
 */ async function updateMetadataRuleset(metadataRuleSetPath, checksums, prettifyJson) {
    console.log('Path to metadata ruleset:', metadataRuleSetPath);
    const rawMetadataRuleset = await promises.readFile(metadataRuleSetPath, {
        encoding: 'utf-8'
    });
    const metadataRuleset = MetadataRuleSet.deserialize(rawMetadataRuleset);
    // Update each checksum in the metadata ruleset instead of recreating whole
    // ruleset to keep all additional properties not touched by this operation.
    Object.entries(checksums).forEach(([ruleSetId, checksum])=>{
        metadataRuleset.setChecksum(ruleSetId, checksum);
    });
    await promises.writeFile(metadataRuleSetPath, metadataRuleset.serialize(prettifyJson));
    console.log(`Metadata ruleset updated and saved to ${metadataRuleSetPath}`);
}
/**
 * Excludes unsafe rules from rulesets and places them into the metadata rule.
 *
 * @param params Parameters for processing rulesets.
 * @param params.dir The directory containing the rulesets to process.
 * @param params.limit Optional limit for the maximum number of unsafe rules
 * to exclude.
 * @param params.prettifyJson Whether to prettify the JSON output.
 */ async function excludeUnsafeRules(params) {
    const { dir, limit, prettifyJson = false } = params;
    const rulesets = await scanFolder(dir);
    console.log('Extracted rulesets:', rulesets.length);
    const totalUnsafeRulesCount = rulesets.reduce((acc, ruleset)=>{
        return acc + ruleset.getUnsafeRulesCount();
    }, 0);
    // If limit is provided, check the number of unsafe rules
    // in all rulesets before processing them.
    if (limit) {
        if (totalUnsafeRulesCount > limit) {
            throw new Error(`Too many unsafe rules found: ${totalUnsafeRulesCount}. Limit is ${limit}.`);
        }
    }
    // After changes in rulesets we will need to update the metadata ruleset with
    // checksums for the rulesets.
    const checksums = {};
    const tasks = rulesets.map(async (ruleset)=>{
        const declarativeRules = await ruleset.getDeclarativeRules();
        const unsafeDeclarativeRules = declarativeRules.filter((rule)=>{
            return !isSafeRule(rule);
        });
        const processedRuleset = await ruleset.serializeCompact(prettifyJson, unsafeDeclarativeRules);
        const ruleSetPath = getRuleSetPath(ruleset.getId(), dir);
        await ensureDir(path.dirname(ruleSetPath));
        await promises.writeFile(ruleSetPath, processedRuleset);
        checksums[ruleset.getId()] = generateMD5Hash(processedRuleset);
        console.log(`========== Ruleset #${ruleset.getId()} ==========`);
        console.log('Moved unsafe rules:', unsafeDeclarativeRules.length);
        console.log('Safety ruleset saved to:', ruleSetPath);
        console.log(`========== Ruleset #${ruleset.getId()} ==========`);
        console.log('\n');
    });
    await Promise.all(tasks);
    await updateMetadataRuleset(getRuleSetPath(METADATA_RULESET_ID, dir), checksums, prettifyJson);
    console.log('Total excluded unsafe rules:', totalUnsafeRulesCount);
    console.log('All rulesets processed and their metadata updated.');
}

export { AssetsLoader, ManifestPatcher, RulesetsInjector, excludeUnsafeRules };
