import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
import {
  normalizeAsukaPortableProductDocument,
  type AsukaPortableProductDocument,
} from './portableProduct';

export const ASUKA_PORTABLE_PACKAGE_SCHEMA = 'asuka-product-package' as const;
export const ASUKA_PORTABLE_PACKAGE_SCHEMA_VERSION = 1 as const;

export type AsukaPortablePackageAsset = {
  id: string;
  path: string;
  filename: string;
  extension: string;
  mime: string;
  bytes: number;
  sha256: string;
  dependencies: string[];
};

export type AsukaPortablePackageManifest = {
  schema: typeof ASUKA_PORTABLE_PACKAGE_SCHEMA;
  schemaVersion: typeof ASUKA_PORTABLE_PACKAGE_SCHEMA_VERSION;
  designerVersion?: string;
  generatedAt: string;
  exporter: { name: string; version: string };
  product: { file: string; name: string; slug: string };
  dependencies: {
    template: { file: string; reference: string; name: string } | null;
    themes: Array<{ file: string; reference: string; name: string }>;
  };
  assets: AsukaPortablePackageAsset[];
  externalHosts: string[];
  options: { assets: boolean; template: boolean; themes: boolean };
};

export type AsukaPortableResolvedAsset = {
  bytes: Uint8Array | ArrayBuffer;
  filename?: string;
  mime?: string;
  dependencies?: string[];
};

export type AsukaPortableAssetResolver = (
  url: string,
  context: { key: string },
) => Promise<AsukaPortableResolvedAsset | null>;

export type AsukaPortableMaterializedAsset = AsukaPortablePackageAsset & {
  data: Uint8Array;
};

export type AsukaPortableAssetPublisher = (
  asset: AsukaPortableMaterializedAsset,
) => Promise<string>;

export type AsukaPortableProductPackageCreateOptions = {
  document: AsukaPortableProductDocument | unknown;
  template?: { reference: string; name: string; document: Record<string, unknown> } | null;
  themes?: Array<{ reference: string; name: string; document: Record<string, unknown> }>;
  includeAssets?: boolean;
  resolveAsset?: AsukaPortableAssetResolver;
  exporter?: { name?: string; version?: string };
  designerVersion?: string;
};

export type AsukaPortableProductPackage = {
  document: AsukaPortableProductDocument;
  manifest: AsukaPortablePackageManifest;
  template: Record<string, unknown> | null;
  themes: Array<Record<string, unknown>>;
  assets: Map<string, AsukaPortableMaterializedAsset>;
};

const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
const MAX_FILE_BYTES = 128 * 1024 * 1024;
const MAX_TOTAL_BYTES = 512 * 1024 * 1024;
const MAX_FILES = 1000;
const MIME_BY_EXTENSION = new Map<string, string>([
  ['avif', 'image/avif'],
  ['bin', 'application/octet-stream'],
  ['gif', 'image/gif'],
  ['glb', 'model/gltf-binary'],
  ['gltf', 'model/gltf+json'],
  ['jpeg', 'image/jpeg'],
  ['jpg', 'image/jpeg'],
  ['otf', 'font/otf'],
  ['png', 'image/png'],
  ['svg', 'image/svg+xml'],
  ['ttf', 'font/ttf'],
  ['webp', 'image/webp'],
  ['woff', 'font/woff'],
  ['woff2', 'font/woff2'],
]);
const EXTENSION_BY_MIME = new Map(
  [...MIME_BY_EXTENSION.entries()].map(([extension, mime]) => [mime, extension]),
);

function asRecord(value: unknown): Record<string, unknown> | null {
  return value && typeof value === 'object' && !Array.isArray(value)
    ? value as Record<string, unknown>
    : null;
}

function safePath(value: string): boolean {
  const path = value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
  return Boolean(
    path
    && !path.includes('../')
    && !path.startsWith('..')
    && !/^[A-Za-z]:/.test(path)
    && !path.includes('\0'),
  );
}

function safeFilename(value: unknown, fallback: string): string {
  const filename = String(value ?? '')
    .split(/[\\/]/)
    .pop()
    ?.normalize('NFKD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[^A-Za-z0-9._-]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 160);
  return filename || fallback;
}

function extensionFromName(value: string): string {
  return value.toLowerCase().match(/\.([a-z0-9]+)(?:[?#].*)?$/)?.[1] ?? '';
}

async function sha256(bytes: Uint8Array): Promise<string> {
  const subtle = globalThis.crypto?.subtle;
  if (!subtle) throw new Error('SHA-256 support is unavailable in this runtime.');
  const digest = await subtle.digest('SHA-256', bytes as BufferSource);
  return [...new Uint8Array(digest)]
    .map((value) => value.toString(16).padStart(2, '0'))
    .join('');
}

function jsonBytes(value: unknown): Uint8Array {
  return strToU8(`${JSON.stringify(value, null, 2)}\n`);
}

function parseJson(bytes: Uint8Array, label: string): Record<string, unknown> {
  try {
    const parsed = JSON.parse(strFromU8(bytes));
    const record = asRecord(parsed);
    if (!record) throw new Error();
    return record;
  } catch {
    throw new TypeError(`${label} is not valid JSON.`);
  }
}

function dataUrlAsset(value: string): AsukaPortableResolvedAsset | null {
  const match = value.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i);
  if (!match) return null;
  const mime = match[1].toLowerCase();
  const extension = EXTENSION_BY_MIME.get(mime);
  if (!extension) return null;
  const raw = globalThis.atob(match[2].replace(/\s+/g, ''));
  const bytes = new Uint8Array(raw.length);
  for (let index = 0; index < raw.length; index += 1) bytes[index] = raw.charCodeAt(index);
  return { bytes, filename: `embedded.${extension}`, mime };
}

async function defaultAssetResolver(
  url: string,
): Promise<AsukaPortableResolvedAsset | null> {
  if (url.startsWith('data:')) return dataUrlAsset(url);
  if (!/^https?:\/\//i.test(url) || typeof fetch !== 'function') return null;
  const response = await fetch(url, { headers: { Accept: '*/*' } });
  if (!response.ok) return null;
  const data = new Uint8Array(await response.arrayBuffer());
  const mime = String(response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
  const parsed = new URL(response.url || url);
  return {
    bytes: data,
    filename: decodeURIComponent(parsed.pathname.split('/').pop() || 'asset'),
    mime,
  };
}

class PortableAssetCollector {
  readonly assets = new Map<string, AsukaPortableMaterializedAsset>();
  readonly externalHosts = new Set<string>();
  private readonly references = new Map<string, string>();
  private totalBytes = 0;

  constructor(private readonly resolver: AsukaPortableAssetResolver) {}

  async rewrite(value: unknown, key = ''): Promise<unknown> {
    if (Array.isArray(value)) return Promise.all(value.map((entry) => this.rewrite(entry, key)));
    const record = asRecord(value);
    if (record) {
      const output: Record<string, unknown> = {};
      for (const [childKey, child] of Object.entries(record)) {
        output[childKey] = await this.rewrite(child, childKey);
      }
      return output;
    }
    if (typeof value !== 'string') return value;
    const source = value.trim();
    if (!source.startsWith('data:') && !/^https?:\/\//i.test(source)) return value;
    if (
      !source.startsWith('data:')
      && !/(url|src|image|model|texture|font|file|preview|thumbnail)/i.test(key)
      && !MIME_BY_EXTENSION.has(extensionFromName(source))
    ) {
      return value;
    }
    try {
      return await this.register(source, key);
    } catch {
      if (/^https?:\/\//i.test(source)) {
        try { this.externalHosts.add(new URL(source).hostname.toLowerCase()); } catch {}
      }
      return value;
    }
  }

  private async register(source: string, key: string): Promise<string> {
    const existing = this.references.get(source);
    if (existing) return existing;
    const resolved = await this.resolver(source, { key });
    if (!resolved) throw new Error('The media could not be resolved.');
    const data = resolved.bytes instanceof Uint8Array
      ? resolved.bytes
      : new Uint8Array(resolved.bytes);
    if (!data.byteLength || data.byteLength > MAX_FILE_BYTES) {
      throw new Error('A media file is empty or exceeds the package limit.');
    }
    if (this.totalBytes + data.byteLength > MAX_TOTAL_BYTES) {
      throw new Error('The product media exceeds the package limit.');
    }
    const suppliedMime = String(resolved.mime ?? '').split(';')[0].trim().toLowerCase();
    let extension = extensionFromName(resolved.filename ?? source);
    if (!MIME_BY_EXTENSION.has(extension)) extension = EXTENSION_BY_MIME.get(suppliedMime) ?? '';
    if (!MIME_BY_EXTENSION.has(extension)) throw new Error('Unsupported media format.');
    const mime = MIME_BY_EXTENSION.get(extension)!;
    const hash = await sha256(data);
    const id = hash.slice(0, 24);
    let filename = safeFilename(resolved.filename, `asset.${extension}`);
    if (!filename.toLowerCase().endsWith(`.${extension}`)) filename = `${filename}.${extension}`;
    if (!this.assets.has(id)) {
      this.assets.set(id, {
        id,
        path: `assets/${id}-${filename}`,
        filename,
        extension,
        mime,
        bytes: data.byteLength,
        sha256: hash,
        dependencies: [...new Set(resolved.dependencies ?? [])],
        data,
      });
      this.totalBytes += data.byteLength;
    }
    const reference = `asuka-asset://${id}`;
    this.references.set(source, reference);
    return reference;
  }
}

function withoutAssetData(asset: AsukaPortableMaterializedAsset): AsukaPortablePackageAsset {
  const { data: _data, ...manifest } = asset;
  return manifest;
}

/** Create a checksummed `.asuka.zip` package in any modern browser or JS runtime. */
export async function createAsukaPortableProductPackage(
  options: AsukaPortableProductPackageCreateOptions,
): Promise<Uint8Array> {
  let document = normalizeAsukaPortableProductDocument(options.document);
  const includeAssets = options.includeAssets !== false;
  const collector = new PortableAssetCollector(options.resolveAsset ?? defaultAssetResolver);
  let template = options.template?.document ?? null;
  let themes = (options.themes ?? []).map(({ document: theme }) => theme);
  if (includeAssets) {
    document = await collector.rewrite(document) as AsukaPortableProductDocument;
    template = template ? await collector.rewrite(template) as Record<string, unknown> : null;
    themes = await Promise.all(themes.map((theme) => collector.rewrite(theme) as Promise<Record<string, unknown>>));
  }

  const files: Record<string, Uint8Array> = {};
  files['product.json'] = jsonBytes(document);
  let templateManifest: AsukaPortablePackageManifest['dependencies']['template'] = null;
  if (template && options.template) {
    files['dependencies/template.json'] = jsonBytes(template);
    templateManifest = {
      file: 'dependencies/template.json',
      reference: options.template.reference,
      name: options.template.name,
    };
  }
  const themeManifest: AsukaPortablePackageManifest['dependencies']['themes'] = [];
  themes.forEach((theme, index) => {
    const input = options.themes?.[index];
    if (!input) return;
    const file = `dependencies/themes/${safeFilename(input.reference.replace(/^theme:/, ''), `theme-${index + 1}`)}.json`;
    files[file] = jsonBytes(theme);
    themeManifest.push({ file, reference: input.reference, name: input.name });
  });
  for (const asset of collector.assets.values()) files[asset.path] = asset.data;

  const manifest: AsukaPortablePackageManifest = {
    schema: ASUKA_PORTABLE_PACKAGE_SCHEMA,
    schemaVersion: ASUKA_PORTABLE_PACKAGE_SCHEMA_VERSION,
    designerVersion: options.designerVersion,
    generatedAt: new Date().toISOString(),
    exporter: {
      name: options.exporter?.name || 'Asuka Designer',
      version: options.exporter?.version || '',
    },
    product: {
      file: 'product.json',
      name: document.metadata.name,
      slug: document.metadata.slug,
    },
    dependencies: { template: templateManifest, themes: themeManifest },
    assets: [...collector.assets.values()].map(withoutAssetData),
    externalHosts: [...collector.externalHosts].sort(),
    options: {
      assets: includeAssets,
      template: Boolean(templateManifest),
      themes: themeManifest.length > 0,
    },
  };
  files['manifest.json'] = jsonBytes(manifest);

  const checksumLines: string[] = [];
  for (const path of Object.keys(files).sort()) {
    checksumLines.push(`${await sha256(files[path])}  ${path}`);
  }
  files['checksums.sha256'] = strToU8(`${checksumLines.join('\n')}\n`);
  return zipSync(files, { level: 6 });
}

async function inputBytes(
  input: Blob | ArrayBuffer | Uint8Array,
): Promise<Uint8Array> {
  if (input instanceof Uint8Array) return input;
  if (typeof Blob !== 'undefined' && input instanceof Blob) {
    return new Uint8Array(await input.arrayBuffer());
  }
  return new Uint8Array(input as ArrayBuffer);
}

/** Read and integrity-check a package before any product or media is created. */
export async function readAsukaPortableProductPackage(
  input: Blob | ArrayBuffer | Uint8Array,
): Promise<AsukaPortableProductPackage> {
  const archive = await inputBytes(input);
  if (!archive.byteLength || archive.byteLength > MAX_ARCHIVE_BYTES) {
    throw new Error('The selected package is empty or too large.');
  }
  let files: Record<string, Uint8Array>;
  try { files = unzipSync(archive); } catch { throw new Error('The selected file is not a readable ZIP package.'); }
  const paths = Object.keys(files);
  if (paths.length > MAX_FILES || paths.some((path) => !safePath(path))) {
    throw new Error('The package contains too many files or an unsafe path.');
  }
  let totalBytes = 0;
  for (const path of paths) {
    if (files[path].byteLength > MAX_FILE_BYTES) throw new Error('A package file exceeds the safety limit.');
    totalBytes += files[path].byteLength;
  }
  if (totalBytes > MAX_TOTAL_BYTES) throw new Error('The expanded package exceeds the safety limit.');
  const manifestBytes = files['manifest.json'];
  const checksumBytes = files['checksums.sha256'];
  if (!manifestBytes || !checksumBytes) throw new Error('The package manifest or checksums are missing.');

  const expected = new Map<string, string>();
  for (const line of strFromU8(checksumBytes).split(/\r?\n/).filter(Boolean)) {
    const match = line.match(/^([a-f0-9]{64}) {2}(.+)$/);
    if (!match || !safePath(match[2]) || expected.has(match[2])) {
      throw new Error('The package checksums are invalid.');
    }
    expected.set(match[2], match[1]);
  }
  const actualPaths = paths.filter((path) => path !== 'checksums.sha256').sort();
  if (actualPaths.join('\n') !== [...expected.keys()].sort().join('\n')) {
    throw new Error('The package file list does not match its checksums.');
  }
  for (const [path, hash] of expected) {
    if (!files[path] || await sha256(files[path]) !== hash) {
      throw new Error('The package is damaged or has been modified.');
    }
  }

  const manifest = parseJson(manifestBytes, 'manifest.json') as unknown as AsukaPortablePackageManifest;
  if (
    manifest.schema !== ASUKA_PORTABLE_PACKAGE_SCHEMA
    || manifest.schemaVersion !== ASUKA_PORTABLE_PACKAGE_SCHEMA_VERSION
  ) {
    throw new Error('The package manifest is not supported.');
  }
  const productBytes = files[manifest.product?.file || 'product.json'];
  if (!productBytes) throw new Error('The portable product document is missing.');
  const document = normalizeAsukaPortableProductDocument(parseJson(productBytes, 'product.json'));
  const assets = new Map<string, AsukaPortableMaterializedAsset>();
  for (const asset of Array.isArray(manifest.assets) ? manifest.assets : []) {
    const data = files[asset.path];
    if (!data || data.byteLength !== asset.bytes || await sha256(data) !== asset.sha256) {
      throw new Error(`The package asset ${asset.filename} is missing or damaged.`);
    }
    assets.set(asset.id, { ...asset, data });
  }
  const templatePath = manifest.dependencies?.template?.file;
  const templateBytes = templatePath ? files[templatePath] : undefined;
  if (templatePath && !templateBytes) throw new Error('The package template dependency is missing.');
  const template = templateBytes ? parseJson(templateBytes, 'template dependency') : null;
  const themes = (manifest.dependencies?.themes ?? []).map(({ file }) => {
    const themeBytes = files[file];
    if (!themeBytes) throw new Error('A package theme dependency is missing.');
    return parseJson(themeBytes, 'theme dependency');
  });
  return { document, manifest, template, themes, assets };
}

function replaceReferences(value: unknown, urls: Map<string, string>): unknown {
  if (Array.isArray(value)) return value.map((entry) => replaceReferences(entry, urls));
  const record = asRecord(value);
  if (record) {
    const output: Record<string, unknown> = {};
    for (const [key, child] of Object.entries(record)) output[key] = replaceReferences(child, urls);
    return output;
  }
  if (typeof value !== 'string') return value;
  return value.replace(
    /asuka-(?:asset|attachment):\/\/([a-f0-9]{24})/g,
    (_match, id: string) => urls.get(id) || '',
  );
}

function bytesToDataUrl(asset: AsukaPortableMaterializedAsset): string {
  let binary = '';
  const chunkSize = 0x8000;
  for (let offset = 0; offset < asset.data.length; offset += chunkSize) {
    binary += String.fromCharCode(...asset.data.subarray(offset, offset + chunkSize));
  }
  return `data:${asset.mime};base64,${globalThis.btoa(binary)}`;
}

/**
 * Publish package assets through the host integration, then return a product
 * document whose `asuka-asset://` references use the resulting URLs.
 */
export async function materializeAsukaPortableProductPackage(
  productPackage: AsukaPortableProductPackage,
  publishAsset: AsukaPortableAssetPublisher = async (asset) => bytesToDataUrl(asset),
): Promise<AsukaPortableProductPackage> {
  const pending = new Map(productPackage.assets);
  const urls = new Map<string, string>();
  const assets = new Map<string, AsukaPortableMaterializedAsset>();
  while (pending.size > 0) {
    const ready = [...pending.values()].filter((asset) =>
      asset.dependencies.every((dependency) => urls.has(dependency) || !pending.has(dependency)));
    if (!ready.length) throw new Error('The package assets contain a dependency cycle.');
    for (const sourceAsset of ready) {
      let asset = sourceAsset;
      if (['gltf', 'svg'].includes(asset.extension)) {
        asset = {
          ...asset,
          data: strToU8(String(replaceReferences(strFromU8(asset.data), urls))),
        };
      }
      const url = await publishAsset(asset);
      if (!url) throw new Error(`The package asset ${asset.filename} could not be published.`);
      urls.set(asset.id, url);
      assets.set(asset.id, asset);
      pending.delete(asset.id);
    }
  }
  return {
    ...productPackage,
    document: normalizeAsukaPortableProductDocument(
      replaceReferences(productPackage.document, urls),
    ),
    template: productPackage.template
      ? replaceReferences(productPackage.template, urls) as Record<string, unknown>
      : null,
    themes: productPackage.themes.map((theme) =>
      replaceReferences(theme, urls) as Record<string, unknown>),
    assets,
  };
}
