

import type {
  Plugin,
  OutputBundle,
  OutputChunk,
  OutputAsset,
  NormalizedOutputOptions,
  // ModuleInfo,
  ResolvedId,
  PreRenderedChunk,
  RenderedChunk,
} from 'rollup';

import type {
  LoadResult,
  RollupHtmlOptions,
  LoadNodeCallback,
  LoadReference, BodyReference, AttributeReference, LoadFunction
} from '../types/index.d.ts';

// createFilter function is a utility that constructs a filter function from include/exclude patterns.
import {createFilter} from '@rollup/pluginutils';
// parse5 package is used for parsing HTML.
import {parse as parseHtml, serialize as serializeHtml, DefaultTreeAdapterMap} from "parse5";
// magic-string to transform code and keeping a sourcemap aligned
import MagicString from "magic-string";


// nodejs imports (io, path)
import path, { extname, dirname } from "node:path";
import {readFile} from "node:fs/promises"
import posix from "node:path/posix";
import crypto from "node:crypto";

// utilities
import {makeLoader, makeInlineId} from "./loader.ts";
import {HtmlImport, HtmlModule} from "./html-module.ts";


const defaults: RollupHtmlOptions = {
  transform: (source: string)=>source,// NO-OP
  load: makeLoader(),
  resolve: ()=>true,
  htmlFileNames: "[name].html",
  include: [
    '**/*.(html|hbs)',// html or handlebars
  ]
};

const modulePrefix = `// <html-module>`;
const moduleSuffix = `// </html-module>`;

/**
 * Creates a Rollup plugin that transforms HTML files.
 *
 * @param {RollupHtmlOptions} opts - The options for the plugin.
 * @returns {Plugin} - The Rollup plugin.
 */
export default function html(opts: RollupHtmlOptions = {}): Plugin {
  const {
    publicPath,
    transform,
    rewriteUrl,
    load,
    htmlFileNames,
    resolve,
    include,
    exclude,
  } = Object.assign(
    {},
    defaults,
    opts
  );
  if(publicPath){ throw new Error("TODO, do something with the public path or throw it out of the options. this is just to stop typescript complaining")}

  let filter = createFilter(include, exclude, {});

  // TODO, we need to clear all these properly at sme point to avoid odd bugs in watch mode
  let virtualSources = new Map<string, string>();
  let addedEntries = new Map<string, string>();
  let entryNames = new Map<string,string>();

  const pluginName = 'html2'; // TODO: Need a better name, and work to strip everything noted below except the short summary
  /**
   * Short summary:
   *   Intercepts the loading of the html files and parses it with parse5.
   *   The parsed result is iterated to check for external references that need to be including in the rollup build (via for example @rollup/plugin-url).
   *   A .js version of the html file is returned to rollup, optionally including a few imports left for rollup to resolve
   *   When the result is generated the rollup result for the html file and any of its inlined assets are stripped from the output.
   *   and replaced with a html file.
   *
   *   Caveats:
   *    - to get the resulting html content file we're evaluating the resulting JS module and take its default export
   *      This evaluation step is done in the host NodeJS context, which might screw up things that expect a browser context
   *      [warn] other plugins such as CJS transformer and hot-reload can severely screw this up.
   *    - to fix the naming of resulting html files, and behave properly when files are entryPoints or not... we're fighting rollup alot
   *      issues are likely...
   *
   *
   *    Rework by testing a stripped down version with JS imports?
   *      - the logic in load should be moved to a transform, properly use rollups ability to specify the plugin should run 'pre' other hooks and see if that allows us to intercept before a commonjs or some other tool horribly transpiles our code
   *      - we might need to know which output is being used to properly extract the html back from the result? (in case of not being included in a JS file)
   */
  return {
    name: pluginName,

    // Track html entrypoints
    buildStart(options){
      entryNames = new Map(Object.entries(typeof(options.input)==='object'?options.input:{[options.input]:[options.input]})
          .map(([k,v])=>[v,k])
      );
    },

    resolveId: {
      async handler(specifier: string,
                    importer: string | undefined,
                    options: { assertions: Record<string, string> }){
        if(virtualSources.has(specifier)) return specifier;
        if(!filter(specifier)) return;

        // Let it be resolved like others (node_modules, project aliases, ..)
        const resolved = await this.resolve(specifier, importer, {
          skipSelf: true,
          ...options,
        });

        if(resolved){
          const moduleExt = extname(resolved.id);
          const moduleName = specifier.replace(new RegExp(`${moduleExt}\$`),''); // strip extension of the name if any

          return {
            ...resolved,
            meta: {
              ...resolved.meta,
              [pluginName]: {
                specifier: specifier,
                id: resolved.id,
                name: moduleName,
                imports: [],
                assetId: null,
                importers: new Set(),
              }
            }
          }
        }
      }
    },
    load: {
      async handler(id: string) {
        if (virtualSources.has(id)) return virtualSources.get(id);
        // if (!filter(id)) return;
      }
    },
    transform: {
      order: 'pre',
      async handler(...args){
        const [code, id] = args;
        if (!filter(id)) return;

        // parse
        const moduleInfo = this.getModuleInfo(id);
        const moduleMeta = moduleInfo!.meta ?? {};
        let htmlModule = moduleMeta[pluginName];
        if(!htmlModule){
          const moduleExt = extname(id);
          const moduleName = id.replace(new RegExp(`${moduleExt}\$`),''); // strip extension of the name if any
          htmlModule = moduleMeta[pluginName] = {
            id: id,
            name: moduleName,
            imports: [],
            assetId: null,
            importers: new Set(),
          }
        }
        const contents = code;

        const htmlSrc = transform ? await transform(contents, {
          id,
        }) : contents;

        // Parse document and store it
        const document = htmlModule.document = parseHtml(htmlSrc);

        // TODO working on this: to preserve sourcemaps as much as possible we're starting the magic string on the raw html source
        // question is if we need to though. sourcemaps only make sense for inlined bits of code
        //let htmlJS =  new MagicString(htmlSrc);// This is where we want to go!

        // Figure out which references to load from this HTML by iterating all nodes (looking for src or href attributes)
        let htmlImports: HtmlImport[] = htmlModule.imports = [];
        if (document.childNodes) {
          let nodeQueue = document.childNodes;
          do {
            const nextQueue: DefaultTreeAdapterMap['childNode'][][] = [];
            await Promise.all(nodeQueue.map(async (node) => {
              const el = (<DefaultTreeAdapterMap['element']>node);
              const loadFunction: LoadFunction = async ({
                                                    id: sourceId,
                                                    source,
                                                    type
              })=>{
                if(!sourceId){
                  sourceId = makeInlineId(id, node, 'js');
                }
                if(source){
                  virtualSources.set(sourceId, source);
                }
                const resolved = await this.resolve(sourceId, id, {
                  isEntry: type==='entryChunk',
                });
                if(!resolved){
                  throw new Error(`Could not resolve ${sourceId} from ${id}`);
                }

                const selfInfo = this.getModuleInfo(id);

                let entryName: string|undefined = undefined;
                const parentName = entryNames.get(id)??selfInfo?.meta[pluginName].name;
                if(type==='entryChunk'){
                  entryName= posix.join(posix.dirname(parentName),sourceId);
                  entryName = entryName.slice(0,-(posix.extname(entryName).length)); // Cut off the extension (TODO, is this wise?)
                }

                const importName = (source && selfInfo?.meta[pluginName].name)
                    ? makeInlineId(parentName, node, extname(sourceId))
                    : entryName;

                const htmlImport: HtmlImport = {
                  id: <string>sourceId,
                  resolved: resolved,
                  // loaded: loaded,
                  node: el,
                  type,
                  source,
                  referenceId:
                      (resolved && (['chunk','entryChunk'].includes(type!))) ? this.emitFile({
                        type: 'chunk', // Might want to adapt, or make configurable (see LoadType)
                        id: resolved.id,
                        name: importName,
                        importer: id,
                      }) : null,
                  placeholder: `html-import-${crypto.randomBytes(32).toString('base64')}`,
                  index: htmlImports.length,
                }
                // if(entryName){
                //   addedEntries.set(resolved.id, entryName);// (we could do this using meta?)
                // }
                htmlImports.push(htmlImport);
                return htmlImport.placeholder;
              }

              let toLoad: LoadResult | undefined = load? await Promise.resolve(load({
                node: el,
                sourceId: id
              }, loadFunction)) : undefined;

              if (toLoad !== false) {
                let asParent = (<DefaultTreeAdapterMap['parentNode']>node);
                if (asParent.childNodes) {
                  nextQueue.push(asParent.childNodes);
                }
              }
            }));
            nodeQueue = nextQueue.flat();
          } while (nodeQueue.length > 0);
        }

        // Beware leak of AST (we're starting MagicString on a parsed and modified version of the HTML file, sourcemappings in the HTML file will be off. (can't add a sourcemap for a html file anyway, unless it is outputted as JS module)
        // @ts-ignore
        let htmlJS = new MagicString(serializeHtml(htmlModule.document));
        htmlJS.replaceAll(/`/g,'\\\`').replaceAll(/\$\{/g,'\\${');

        const moduleImports = [];
        for(const htmlImport of htmlImports){
          if(htmlImport.type === 'default') {
            const assetId: string = `asset${moduleImports.length}`;
            moduleImports.push(`import ${assetId} from "${htmlImport.id}";`);// TODO: This is just the easy & safe solution. Would prefer to have recognizable names, and reeuse when something is the exact same resource..
            htmlJS = htmlJS.replace(htmlImport.placeholder, `\${${assetId}}`);// TODO: Should we be worried about windows absolute URLs here?
          }else{
            // TODO: this will probably not do for complicated cases ( presumably no other method then emitting the chunk as file, loading its result but excluding it from the output bundle)
            // html = html.replace(htmlImport.placeholder, htmlImport.loaded?.code||htmlImport.source||'');
          }
        }

        // Import all dependencies and wrap the HTML in a `...`, assign to a var and export (escaping any ` characters in the HTML)
        htmlJS.prepend([
          ...moduleImports,
          `export const html = \``
        ].join('\n')).append([
          `\`;`,
          `export default html;`,
        ].join('\n'));

        const map = htmlJS.generateMap({
          source: id,
          file: `${id}.map`,
          includeContent: true,
          hires: 'boundary'
        });

        return {
          code: htmlJS.toString(),
          map: map.toString(),
          meta: moduleMeta,
        };
      }
    },
    outputOptions(options){
      return {
        ...options,
        entryFileNames: (chunkInfo)=>{
          const moduleInfo = chunkInfo.facadeModuleId? this.getModuleInfo(chunkInfo.facadeModuleId) : null;
          const htmlModule = moduleInfo?.meta?.[pluginName];
          // const htmlModule = chunkInfo.facadeModuleId ? htmlModules.get(chunkInfo.facadeModuleId!) : null;
          const addedEntry = chunkInfo.facadeModuleId ? addedEntries.get(chunkInfo.facadeModuleId!) : null;
          const defaultOption = options.entryFileNames ?? "[name]-[hash].js";// This default is copied from the docs. TODO: don't like overwrite it this way, can we remove the need for this or fetch the true default?
          if(htmlModule){
            let fileName = typeof (htmlFileNames) === 'string' ? htmlFileNames : (<(chunkInfo:PreRenderedChunk)=>string>htmlFileNames)(chunkInfo);
            if(fileName) {
              return fileName;
            }
          }else if(addedEntry){
            return addedEntry;
          }
          return typeof (defaultOption) === 'string' ? defaultOption : (<(chunkInfo:PreRenderedChunk)=>string>defaultOption)(chunkInfo);
        },
        // TODO do we need to do the same for chunks?? (what if they're dynamically imported?)
      }
    },
    resolveFileUrl(options){
      const moduleInfo = this.getModuleInfo(options.moduleId);
      const htmlModule = moduleInfo?.meta?.[pluginName];
      if(htmlModule){
        // Simply use the relative path in our HTML-fileURLs instead of the default `new URL('${fileName}', document.baseURI).href`)
        return `"${options.relativePath}"`;
      }
    },
    banner: {
      // Injects a tag so we know where our bundle starts so we can safely ignore other stuff addded via a banner (ie. live-reload)
      order:'post',
      handler(chunk: RenderedChunk){
        if(chunk.facadeModuleId) {
          const moduleInfo = chunk.facadeModuleId? this.getModuleInfo(chunk.facadeModuleId) : null;
          const htmlModule = moduleInfo?.meta?.[pluginName];
          // const htmlModule = htmlModules.get(chunk.facadeModuleId);
          if (htmlModule) {
            return modulePrefix; // Overwrite any added banner with our own
          }
        }
        return '';
      }
    },
    async generateBundle(outputOptions, bundles){
      const bundleItems = Object.entries(bundles);
      const virtualBundles = new Set<string>();
      const facadeToChunk = new Map<string,OutputChunk>();
      const htmlResults = new Map<string, {chunk: OutputChunk, htmlModule: HtmlModule}>();

      for(const [bundleName, bundle] of bundleItems) {
        const chunk = (<OutputChunk>bundle);
        if(chunk.facadeModuleId) {
          facadeToChunk.set(chunk.facadeModuleId, chunk);

          const moduleInfo = this.getModuleInfo(chunk.facadeModuleId);
          const htmlModule = moduleInfo?.meta?.[pluginName];
          // const htmlModule = htmlModules.get(chunk.facadeModuleId);

          if(htmlModule){ htmlResults.set(bundleName, {chunk, htmlModule})}
          else if(virtualSources.has(chunk.facadeModuleId)){
            virtualBundles.add(bundleName);
          }
        }
      }

      for(const [bundleName, {chunk, htmlModule}] of htmlResults.entries()){
        if(htmlModule. document) {
          // Delete the placeholder chunk from the bundle and emit an asset file for the HTML instead.
          deleteFromBundle(bundleName, bundles);

          // Interpret the module and take its default export (TODO: if [NodeJS vm SourceTextModule](https://nodejs.org/api/vm.html#class-vmsourcetextmodule) ever lands, it would be cleaner to use that one instead of directly importing it)
          let htmlContents: string;

          // Take out the sourceMapUrl if any (it will not have been written yet and tends to cause a crash, we don't need it anyway))
          let sanitizedCode = chunk.code;

          // Use the modulePrefix to filter out prepended code that is not relevant for us (like live-reload)
          const moduleStart = sanitizedCode.indexOf(modulePrefix);
          if(moduleStart>=0){
            sanitizedCode = sanitizedCode.slice(moduleStart+modulePrefix.length);
          }
          // Filter out any sourceMapping url that may have been added
          const sourceMapRE = /\/\/# sourceMappingURL=(.+)/.exec(sanitizedCode);
          if(sourceMapRE){
            sanitizedCode = sanitizedCode.slice(0,sourceMapRE.index)+sanitizedCode.slice(sourceMapRE.index+sourceMapRE[0].length);
          }

          // Encode into a url that we can import(...)
          // const importUrl = `data:text/javascript;base64,${Buffer.from(sanitizedCode).toString('base64')}`; // Safer, but unrecognizable if this throws an error
          const importUrl = `data:text/javascript,${encodeURIComponent(sanitizedCode)}`; // Due to needed encoding still hard to read, but it might at least be recognizable by the user if it throws an error

          try{
            let htmlJsModule = await import(importUrl);
            htmlContents = htmlJsModule.default;
          }catch(err){
            throw new Error([
              `Failed to parse resulting HTML-module. Most likely this is due to a plugin that has altered the module in such a way that we cannot easely evaluate it in NodeJS.`,
              `The code we tried to evaluate:`,
              sanitizedCode.split('\n').map(x=>`  ${x}`).join('\n'),
              `The error we got:`,
              err
            ].join('\n'))
            // TODO: We could try to fallback as follows, but the issues are likely to persist in the end result
            // for(const htmlImport of htmlModule.imports){
            //   if(htmlImport.referenceId) {
            //     const fileName = this.getFileName(htmlImport.referenceId);
            //     htmlImport.reference.set(fileName);
            //   }
            // }
            // serialized = serializeHtml(htmlModule.document);
          }

          // Inject the inlined chunks (TODO cleanup)
          for(const htmlImport of htmlModule.imports){
            const importResult = facadeToChunk.get(htmlImport.resolved?.id!);
            if(importResult){
              if(htmlImport.type === 'chunk') {
                htmlContents = htmlContents.replace(htmlImport.placeholder, importResult.code);
              }else if(htmlImport.type === 'entryChunk'){
                const relPath = posix.relative(dirname(chunk.fileName), importResult.fileName);
                const rootPath = path.posix.join(dirname(chunk.fileName), relPath);
                const rewritten = rewriteUrl? await Promise.resolve(rewriteUrl(relPath, {
                  from: chunk.fileName,
                  rootPath,
                })): relPath;
                htmlContents = htmlContents.replace(htmlImport.placeholder, rewritten);
              }
            }
          }

          this.emitFile({
            type: 'asset',
            name: htmlModule.name,
            fileName: chunk.fileName,
            source: htmlContents,
          });
        }else{
          throw new Error('something went wrong...');
        }
      }
      for( const bundleName of virtualBundles.keys()){
        deleteFromBundle(bundleName, bundles, false);
      }
    }
  };
}

function deleteFromBundle(bundlename: string, bundle: OutputBundle, deleteMap: boolean = true){
  delete bundle[bundlename];
  if(deleteMap) {
    delete bundle[`${bundlename}.map`];// Also delete any generated map files because they don't make any sense. (TODO: there seems to be no better way to detect this?)
  }
}
