import type { Service } from '../types';
import Locator from "../Locator";

/** Descriptor for a single asset entry in a manifest bundle. */
export interface ManifestAsset
{
    /** Optional human readable identifier for the asset. */
    alias?: string;
    /** Optional legacy name field supported by PixiJS manifests. */
    name?: string;
    /** Path to the asset file or pattern. */
    src: string;
    /** Additional PixiJS specific metadata. */
    [key: string]: unknown;
}

/** Group of related assets that should be loaded together. */
export interface ManifestBundle
{
    /** Unique bundle identifier. */
    name: string;
    /** Assets that belong to the bundle. */
    assets: ManifestAsset[];
    /** Additional PixiJS specific metadata. */
    [key: string]: unknown;
}

/** Minimal manifest structure compatible with PixiJS v7 `Assets` loader. */
export interface AssetsManifest
{
    /** Optional collection of bundles to load. */
    bundles?: ManifestBundle[];
    /** Additional PixiJS specific metadata. */
    [key: string]: unknown;
}

/** Function signature used to fetch external manifests. */
export type ManifestFetcher = (source: string) => Promise<AssetsManifest>;

/** Optional configuration accepted by {@link ManifestService}. */
export interface ManifestServiceOptions
{
    /** Base URL that is prepended to relative manifest sources. */
    basePath?: string;
    /** Query parameters appended to every remote manifest request. */
    defaultSearchParams?: ManifestSearchParams;
    /** Optional set of manifest transformers to run after load and before merge. */
    transformers?: ManifestTransformer[];
}

/** Supported value formats for `defaultSearchParams`. */
export type ManifestSearchParams =
  | string
  | URLSearchParams
  | Record<string, string | number | boolean | null | undefined>
  | Iterable<[string, string]>;

/** Context passed into manifest transformers. */
export interface ManifestTransformContext
{
    /** Absolute URL the manifest was loaded from, if any. */
    sourceUrl?: string;
    /** Reference to the service (can be useful for reading config). */
    service: ManifestService;
}

/** Transformer interface for extending manifest processing. */
export interface ManifestTransformer
{
    /** Unique transformer name (for debugging/ordering). */
    name: string;
    /** Lower number runs earlier; default 1000. */
    order?: number;
    /** Transform a manifest and return it (may be async). */
    transform(manifest: AssetsManifest, ctx: ManifestTransformContext): AssetsManifest | Promise<AssetsManifest>;
}

/**
 * Service responsible for collecting, loading and merging PixiJS asset manifests.
 *
 * The service keeps track of manifest sources added by the application, fetches them
 * on demand and merges the resulting manifests while respecting bundle overrides.
 */
export default class ManifestService implements Service
{
    public name = 'ManifestService';
    public readonly serviceType = 'manifest';

    private readonly _sources: ManifestSource[] = [];
    private readonly _fetchManifest: ManifestFetcher;
    private readonly _basePath: string;
    private readonly _searchParamString?: string;
    private readonly _transformers: ManifestTransformer[] = [];

    public constructor();
    public constructor(fetcher: ManifestFetcher);
    public constructor(options: ManifestServiceOptions, fetcher?: ManifestFetcher);
    public constructor(
      optionsOrFetcher: ManifestServiceOptions | ManifestFetcher = defaultFetcher,
      maybeFetcher: ManifestFetcher = defaultFetcher,
    )
    {
        if (typeof optionsOrFetcher === 'function')
        {
            this._fetchManifest = optionsOrFetcher;
            this._basePath = '';
            this._searchParamString = undefined;
            return;
        }

        const options = optionsOrFetcher ?? {};

        this._fetchManifest = maybeFetcher ?? defaultFetcher;
        this._basePath = options.basePath ?? '';
        this._searchParamString = normalizeSearchParams(options.defaultSearchParams);

        if (options.transformers?.length)
        {
            for (const t of options.transformers) this.addTransformer(t);
        }
    }

    /** Register a manifest source. Accepts either a URL or an inline manifest object. */
    public add(source: string | AssetsManifest): this
    {
        if (typeof source === 'string')
        {
            this._sources.push({ kind: 'url', url: source });
        }
        else
        {
            this._sources.push({ kind: 'inline', manifest: cloneManifest(source) });
        }

        return this;
    }

    /** Register a manifest transformer. */
    public addTransformer(transformer: ManifestTransformer): this
    {
        this._transformers.push(transformer);
        this._transformers.sort((a, b) => (a.order ?? 1000) - (b.order ?? 1000));
        return this;
    }

    /** Remove a transformer by name. */
    public removeTransformer(name: string): this
    {
        const idx = this._transformers.findIndex(t => t.name === name);
        if (idx >= 0) this._transformers.splice(idx, 1);
        return this;
    }

    /** Load every manifest registered through {@link add} and apply transformers. */
    public async load(): Promise<void>
    {
        await Promise.all(
          this._sources.map(async (entry) =>
          {
              // 1) Ensure manifest is present
              if (entry.kind === 'url' && !entry.manifest)
              {
                  const sourceUrl = this.resolveSourceUrl(entry);
                  entry.resolvedUrl = sourceUrl;
                  entry.manifest = cloneManifest(await this._fetchManifest(sourceUrl));
              }

              if (!entry.manifest)
              {
                  // inline без манифеста невозможен, но на всякий
                  return;
              }

              // 2) Apply transformers to this manifest (inline и url — одинаково)
              const ctx: ManifestTransformContext = {
                  sourceUrl: entry.kind === 'url' ? (entry.resolvedUrl ?? entry.url) : undefined,
                  service: this,
              };

              entry.manifest = await this.applyTransforms(entry.manifest, ctx);
          }),
        );
    }

    /** Build an absolute manifest URL that honours the configured base path and search params. */
    private resolveSourceUrl(entry: RemoteManifestSource): string
    {
        const baseResolved = resolveBasePath(this._basePath, entry.url);
        return appendSearchParams(baseResolved, this._searchParamString);
    }

    /** Merge loaded manifests, letting later manifests override bundle assets from earlier ones. */
    public getMerged(): AssetsManifest
    {
        if (!this._sources.length)
        {
            return { bundles: [] };
        }

        const manifests = this._sources.map((source) =>
        {
            if (!source.manifest)
            {
                throw new Error('ManifestService: attempting to access manifests before calling load().');
            }
            return source.manifest;
        });

        return mergeManifests(manifests);
    }

    /** Remove all registered sources and cached manifests. */
    public clear(): void
    {
        this._sources.length = 0;
    }

    public dispose(): void
    {
        this.clear();
    }

    /** Run transformers sequentially. */
    private async applyTransforms(manifest: AssetsManifest, ctx: ManifestTransformContext): Promise<AssetsManifest>
    {
        let current = cloneManifest(manifest);
        for (const t of this._transformers)
        {
            current = await t.transform(current, ctx);
            // гарантируем структурную целостность
            current = cloneManifest(current);
        }
        return current;
    }
}

interface InlineManifestSource
{
    kind: 'inline';
    manifest: AssetsManifest;
}

interface RemoteManifestSource
{
    kind: 'url';
    url: string;
    resolvedUrl?: string;
    manifest?: AssetsManifest;
}

type ManifestSource = InlineManifestSource | RemoteManifestSource;

const defaultFetcher: ManifestFetcher = async (source: string) =>
{
    const response = await fetch(source);

    if (!response.ok)
    {
        throw new Error(`ManifestService: unable to load manifest from ${source} (${response.status})`);
    }

    return response.json() as Promise<AssetsManifest>;
};

/** Convert supported search parameter formats into a query string. */
function normalizeSearchParams(params?: ManifestSearchParams): string | undefined
{
    if (!params)
    {
        return undefined;
    }

    if (typeof params === 'string')
    {
        const normalized = params.trim().replace(/^[?&]+/, '');
        return normalized.length ? normalized : undefined;
    }

    if (params instanceof URLSearchParams)
    {
        const normalized = params.toString();
        return normalized.length ? normalized : undefined;
    }

    if (isIterableOfTuples(params))
    {
        // Типобезопасно для Iterable<[string,string]>
        const searchParams = new URLSearchParams(Array.from(params));
        const normalized = searchParams.toString();
        return normalized.length ? normalized : undefined;
    }

    const searchParams = new URLSearchParams();
    const record = params as Record<string, string | number | boolean | null | undefined>;

    for (const [key, value] of Object.entries(record))
    {
        if (value === undefined || value === null) continue;
        searchParams.append(key, String(value));
    }

    const normalized = searchParams.toString();
    return normalized.length ? normalized : undefined;
}

/** Combine the configured base path with a relative manifest URL. */
function resolveBasePath(basePath: string, relativeUrl: string): string
{
    if (!basePath || isAbsoluteUrl(relativeUrl)) return relativeUrl;

    const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/`;

    if (!relativeUrl) return normalizedBase;

    if (relativeUrl.startsWith('/'))
    {
        return normalizedBase.endsWith('/')
          ? normalizedBase.slice(0, -1) + relativeUrl
          : normalizedBase + relativeUrl;
    }

    return `${normalizedBase}${relativeUrl}`;
}

/** Append normalized search parameters to a URL. */
function appendSearchParams(url: string, params?: string): string
{
    if (!params) return url;
    const separator = url.includes('?') ? '&' : '?';
    return `${url}${separator}${params}`;
}

/** Detect if the provided URL already points to an absolute location. */
function isAbsoluteUrl(url: string): boolean
{
    return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url) || url.startsWith('//');
}

/** Type guard that checks whether the value implements the iterable protocol. */
function isIterableOfTuples(value: ManifestSearchParams): value is Iterable<[string, string]>
{
    return typeof (value as Iterable<unknown>)[Symbol.iterator] === 'function';
}

/** Merge manifests in order; later manifests win. */
function mergeManifests(manifests: AssetsManifest[]): AssetsManifest
{
    if (!manifests.length)
    {
        return { bundles: [] };
    }

    const [first, ...rest] = manifests;
    const result: AssetsManifest = cloneManifest(first);

    for (const manifest of rest)
    {
        if (!manifest.bundles?.length) continue;

        result.bundles ??= [];

        for (const bundle of manifest.bundles)
        {
            const existingBundle = result.bundles.find((entry) => entry.name === bundle.name);

            if (!existingBundle)
            {
                result.bundles.push(cloneBundle(bundle));
                continue;
            }

            mergeBundleAssets(existingBundle, bundle);
        }
    }

    return result;
}

/**
 * Merge rule:
 * - ассеты идентифицируются ТОЛЬКО по name;
 * - при совпадении name — полный REPLACE объекта ассета;
 * - если ассета с таким name не было — он добавляется.
 */
function mergeBundleAssets(target: ManifestBundle, source: ManifestBundle): void
{
    if (!source.assets?.length) return;

    target.assets ??= [];

    for (const asset of source.assets)
    {
        const key = getAssetKey(asset); // гарантирует наличие name
        const index = target.assets.findIndex((existing) => getAssetKey(existing) === key);

        if (index >= 0)
        {
            target.assets[index] = cloneAsset(asset); // полная замена
        }
        else
        {
            target.assets.push(cloneAsset(asset));    // добавление нового
        }
    }
}

/** Единственный ключ — name. Бросаем ошибку, если name отсутствует. */
function getAssetKey(asset: ManifestAsset): string
{
    if (!asset.name)
    {
        throw new Error(`ManifestService: asset has no 'name' (src: ${asset.src}). Merge requires 'name'.`);
    }
    return asset.name;
}

function cloneManifest(manifest: AssetsManifest): AssetsManifest
{
    return {
        ...manifest,
        bundles: manifest.bundles?.map(cloneBundle),
    };
}

function cloneBundle(bundle: ManifestBundle): ManifestBundle
{
    return {
        ...bundle,
        assets: bundle.assets?.map(cloneAsset) ?? [],
    };
}

function cloneAsset(asset: ManifestAsset): ManifestAsset
{
    return { ...asset };
}


export const SpineAtlasBasePathTransformer: ManifestTransformer = {
    name: 'SpineAtlasBasePathTransformer',
    order: 500, // выполнится раньше дефолтных, если появятся
    transform(manifest, _ctx) {
        if (!manifest.bundles?.length) return manifest;

        const result = cloneManifest(manifest);

        for (const bundle of result.bundles ?? [])
        {
            for (const asset of bundle.assets ?? [])
            {
                const data = (asset as any).data as { spineAtlasFile?: string } | undefined;
                if (data?.spineAtlasFile)
                {
                    const baseUrl = Locator.network.baseAssetsUrl;
                    const prefix = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
                    data.spineAtlasFile = `${prefix}${data.spineAtlasFile}`;
                }
            }
        }

        return result;
    },
};
