import {
  TextureLoader,
  BufferAttribute,
  Color,
  DoubleSide,
  MeshPhongMaterial,
  MeshStandardMaterial,
  PointsMaterial,
  LineBasicMaterial,
  RepeatWrapping,
  ClampToEdgeWrapping,
  MirroredRepeatWrapping,
  NearestFilter,
  NearestMipmapNearestFilter,
  NearestMipmapLinearFilter,
  LinearFilter,
  LinearMipmapNearestFilter,
  LinearMipmapLinearFilter,
  SRGBColorSpace,
} from "three";

const GL_TO_THREE_WRAP = {
  33071: ClampToEdgeWrapping,
  33648: MirroredRepeatWrapping,
  10497: RepeatWrapping,
};

const GL_TO_THREE_FILTER = {
  9728: NearestFilter,
  9729: LinearFilter,
  9984: NearestMipmapNearestFilter,
  9985: LinearMipmapNearestFilter,
  9986: NearestMipmapLinearFilter,
  9987: LinearMipmapLinearFilter,
};

export const GL_COMPONENT_TYPES = {
  5120: Int8Array,
  5121: Uint8Array,
  5122: Int16Array,
  5123: Uint16Array,
  5125: Uint32Array,
  5126: Float32Array,
};

export const GL_CONSTANTS = {
  FLOAT: 5126,
  //FLOAT_MAT2: 35674,
  FLOAT_MAT3: 35675,
  FLOAT_MAT4: 35676,
  FLOAT_VEC2: 35664,
  FLOAT_VEC3: 35665,
  FLOAT_VEC4: 35666,
  LINEAR: 9729,
  REPEAT: 10497,
  SAMPLER_2D: 35678,
  POINTS: 0,
  LINES: 1,
  LINE_LOOP: 2,
  LINE_STRIP: 3,
  TRIANGLES: 4,
  TRIANGLE_STRIP: 5,
  TRIANGLE_FAN: 6,
  UNSIGNED_BYTE: 5121,
  UNSIGNED_SHORT: 5123,
};

const MAX_GAP = 128 * 1024; // 128 KB
const MAX_CHUNK = 30 * 1024 * 1024; // 100 MB

export class GltfStructure {
  constructor(id, loadController) {
    this.id = `${id}`;
    this.json = null;
    this.loadController = loadController;
    this.loader = null;
    this.batchDelay = 10;
    this.maxBatchSize = 5 * 1024 * 1024;
    this.maxRangesPerRequest = 512;
    this.pendingRequests = [];
    this.batchTimeout = null;
    this.textureLoader = new TextureLoader();
    this.textureLoader.crossOrigin = "anonymous";
    this.materials = new Map();
    this.textureCache = new Map();
    this.materialCache = new Map();
    this.uri = "";
    this._nextObjectId = 1;
    this.loadingAborted = false;
    this.criticalError = null;
    // Holds decoded base64 buffer when the .gltf has an embedded data URI
    this.embeddedBinaryChunk = null;
  }

  async initialize(loader) {
    const json = await this.loadController.loadJson();

    if (json.asset === undefined || json.asset.version[0] < 2) {
      throw new Error("DynamicLoader: Unsupported asset. glTF versions >=2.0 are supported.");
    }

    this.json = json;
    this.loader = loader;

    const bufferUri = this.json.buffers?.[0]?.uri || "";

    // Detect embedded data URI (e.g. "data:application/octet-stream;base64,...")
    // and decode it once into memory. The buffer URI is then cleared so that
    // scheduleRequest serves data from memory instead of doing network requests.
    if (bufferUri.startsWith("data:")) {
      this.embeddedBinaryChunk = await this._decodeDataUri(bufferUri);
      this.uri = "";
    } else {
      this.uri = bufferUri;
    }
  }

  // Decode a data URI to ArrayBuffer using the browser's native fetch() —
  // much faster than atob() + JS loop for large (10+ MB) base64 buffers.
  async _decodeDataUri(dataUri) {
    try {
      const response = await fetch(dataUri);
      return await response.arrayBuffer();
    } catch (e) {
      throw new Error(`DynamicLoader: Failed to decode embedded data URI: ${e.message}`);
    }
  }

  clear() {
    if (this.batchTimeout) {
      clearTimeout(this.batchTimeout);
      this.batchTimeout = null;
    }

    this._rejectPendingRequests();

    if (this.disposeMaterials) {
      try {
        this.disposeMaterials();
      } catch (e) {
        console.warn("DynamicLoader: error during disposeMaterials in clear():", e);
      }
    }
    if (this.textureCache) this.textureCache.clear();
    if (this.materials) this.materials.clear();

    this.embeddedBinaryChunk = null;
    this.json = null;
    this.loadController = null;
    this.uri = "";

    this.activeChunkLoads = 0;
    this.chunkQueue = [];
    this.loadingAborted = false;
    this.criticalError = null;
  }

  _rejectPendingRequests() {
    const pending = this.pendingRequests;
    this.pendingRequests = [];
    if (!pending || pending.length === 0) return;

    const cancelError = new Error("DynamicLoader: Structure cleared while requests pending");
    for (let i = 0; i < pending.length; i++) {
      const item = pending[i];
      if (item && typeof item._reject === "function") {
        try {
          item._reject(cancelError);
        } catch {
          // Consumer may have already been disposed
        }
      }
    }
  }

  getJson() {
    return this.json;
  }

  scheduleRequest(request) {
    // Fast path: buffer was an embedded data URI, decoded once into memory
    if (this.embeddedBinaryChunk && !this.uri) {
      return Promise.resolve({
        buffer: this.embeddedBinaryChunk,
        relOffset: request.offset,
        length: request.length,
      });
    }

    return new Promise((resolve, reject) => {
      if (this.loadingAborted) {
        reject(
          this.criticalError || new Error("DynamicLoader: Structure loading has been aborted due to critical error")
        );
        return;
      }
      this.pendingRequests.push({
        ...request,
        _resolve: resolve,
        _reject: reject,
      });
    });
  }

  isCriticalHttpError(error) {
    if (!error) return false;

    const status = error.status || error.statusCode || error.code;

    if (typeof status === "number") {
      return status >= 400 && status < 600;
    }

    if (error.message) {
      const match = error.message.match(/HTTP\s+(\d{3})/i);
      if (match) {
        const code = parseInt(match[1], 10);
        return code >= 400 && code < 600;
      }
    }

    return false;
  }

  abortLoading(error) {
    if (this.loadingAborted) {
      return;
    }

    this.loadingAborted = true;
    this.criticalError = error;

    const requests = [...this.pendingRequests];
    this.pendingRequests = [];

    for (const req of requests) {
      if (req._reject) {
        req._reject(error);
      }
    }

    console.error(
      `DynamicLoader: Critical error for structure "${this.id}". All further loading aborted.`,
      `\n   Error: ${error.message || error}`,
      `\n   Rejected ${requests.length} pending chunk requests.`
    );

    throw error;
  }

  async flushBufferRequests() {
    if (!this.pendingRequests || this.pendingRequests.length === 0) return;
    const requests = [...this.pendingRequests];
    this.pendingRequests = [];

    requests.sort((a, b) => a.offset - b.offset);
    const mergedRanges = [];
    let current = {
      start: requests[0].offset,
      end: requests[0].offset + requests[0].length,
      requests: [requests[0]],
    };
    for (let i = 1; i < requests.length; i++) {
      const req = requests[i];
      const gap = req.offset - current.end;
      const newEnd = Math.max(current.end, req.offset + req.length);
      const projectedSize = newEnd - current.start;
      if (gap <= MAX_GAP && projectedSize <= MAX_CHUNK) {
        current.end = newEnd;
        current.requests.push(req);
      } else {
        mergedRanges.push(current);
        current = {
          start: req.offset,
          end: req.offset + req.length,
          requests: [req],
        };
      }
    }
    mergedRanges.push(current);
    const finalRanges = [];
    for (const range of mergedRanges) {
      let { start, end, requests } = range;
      while (end - start > MAX_CHUNK) {
        let splitIdx = 0;
        for (let i = 0; i < requests.length; i++) {
          if (requests[i].offset + requests[i].length - start > MAX_CHUNK) {
            break;
          }
          splitIdx = i;
        }
        const chunkRequests = requests.slice(0, splitIdx + 1);
        const chunkEnd =
          chunkRequests[chunkRequests.length - 1].offset + chunkRequests[chunkRequests.length - 1].length;
        finalRanges.push({
          start,
          end: chunkEnd,
          requests: chunkRequests,
        });
        requests = requests.slice(splitIdx + 1);
        if (requests.length > 0) {
          start = requests[0].offset;
          end = requests[0].offset + requests[0].length;
          for (let i = 1; i < requests.length; i++) {
            end = Math.max(end, requests[i].offset + requests[i].length);
          }
        }
      }
      if (requests.length > 0) {
        finalRanges.push({ start, end, requests });
      }
    }

    const promises = finalRanges.map(async (range, index) => {
      if (this.loadingAborted) {
        for (const req of range.requests) {
          req._reject(this.criticalError || new Error("DynamicLoader: Structure loading aborted"));
        }
        return;
      }

      await this.loader.waitForChunkSlot();

      try {
        const length = range.end - range.start;
        const buffer = await this.loadController.loadBinaryData([{ offset: range.start, length }], this.uri);

        for (const req of range.requests) {
          const relOffset = req.offset - range.start;
          try {
            req._resolve({ buffer, relOffset, length: req.length });
          } catch (e) {
            req._reject(e);
          }
        }
      } catch (error) {
        for (const req of range.requests) {
          req._reject(error);
        }

        if (this.isCriticalHttpError(error)) {
          this.abortLoading(error);
        } else {
          console.warn(
            `DynamicLoader: Failed to load chunk ${index + 1}/${finalRanges.length} (${range.start}-${range.end}):`,
            error
          );
        }
      } finally {
        this.loader.releaseChunkSlot();
      }
    });

    await Promise.all(promises);

    this.pendingRequests = [];
  }

  getBufferView(byteOffset, byteLength, componentType) {
    return this.scheduleRequest({
      offset: byteOffset,
      length: byteLength,
      componentType,
    });
  }

  createTypedArray(buffer, offset, length, componentType) {
    try {
      if (!buffer || !(buffer instanceof ArrayBuffer)) {
        throw new Error("DynamicLoader: Invalid buffer");
      }

      let elementSize;
      switch (componentType) {
        case 5120:
        case 5121:
          elementSize = 1;
          break; // BYTE, UNSIGNED_BYTE
        case 5122:
        case 5123:
          elementSize = 2;
          break; // SHORT, UNSIGNED_SHORT
        case 5125:
        case 5126:
          elementSize = 4;
          break; // UNSIGNED_INT, FLOAT
        default:
          throw new Error(`DynamicLoader: Unsupported component type: ${componentType}`);
      }

      const numElements = length / elementSize;
      if (!Number.isInteger(numElements)) {
        throw new Error(`DynamicLoader: Invalid length ${length} for component type ${componentType}`);
      }

      if (length > buffer.byteLength) {
        throw new Error(
          `DynamicLoader: Buffer too small: need ${length} bytes, but buffer is ${buffer.byteLength} bytes`
        );
      }

      const ArrayType = GL_COMPONENT_TYPES[componentType];
      return new ArrayType(buffer, offset, numElements);
    } catch (error) {
      if (error.name !== "AbortError") {
        console.warn("DynamicLoader: Error creating typed array:", {
          bufferSize: buffer?.byteLength,
          offset,
          length,
          componentType,
          error,
        });
      }
      throw error;
    }
  }

  async createBufferAttribute(accessorIndex) {
    if (!this.json) {
      throw new Error("DynamicLoader: No GLTF structure loaded");
    }

    const gltf = this.json;
    const accessor = gltf.accessors[accessorIndex];
    const bufferView = gltf.bufferViews[accessor.bufferView];

    try {
      const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0);
      const components = this.getNumComponents(accessor.type);
      const count = accessor.count;
      const byteLength = count * components * this.getComponentSize(accessor.componentType);

      const array = await this.getBufferView(byteOffset, byteLength, accessor.componentType);

      const attribute = new BufferAttribute(array, components);

      if (accessor.min !== undefined) attribute.min = accessor.min;
      if (accessor.max !== undefined) attribute.max = accessor.max;

      return attribute;
    } catch (error) {
      if (error.name !== "AbortError") {
        console.warn("DynamicLoader: Error creating buffer attribute:", {
          error,
          accessor,
          bufferView,
        });
      }

      throw error;
    }
  }

  getComponentSize(componentType) {
    switch (componentType) {
      case 5120: // BYTE
      case 5121: // UNSIGNED_BYTE
        return 1;
      case 5122: // SHORT
      case 5123: // UNSIGNED_SHORT
        return 2;
      case 5125: // UNSIGNED_INT
      case 5126: // FLOAT
        return 4;
      default:
        throw new Error(`DynamicLoader: Unknown component type: ${componentType}`);
    }
  }

  getNumComponents(type) {
    switch (type) {
      case "SCALAR":
        return 1;
      case "VEC2":
        return 2;
      case "VEC3":
        return 3;
      case "VEC4":
        return 4;
      case "MAT2":
        return 4;
      case "MAT3":
        return 9;
      case "MAT4":
        return 16;
      default:
        throw new Error(`DynamicLoader: Unknown type: ${type}`);
    }
  }

  async loadTextures() {
    if (!this.json.textures) return;

    const loadTexture = async (imageIndex) => {
      let textureName = `index_${imageIndex}`;
      try {
        const image = this.json.images[imageIndex];
        textureName = image.uri || image.name || textureName;

        if (image.uri) {
          const fullUrl = await this.loadController.resolveURL(image.uri);
          return await this.textureLoader.loadAsync(fullUrl);
        } else if (image.bufferView !== undefined) {
          const bufferView = this.json.bufferViews[image.bufferView];
          const { buffer, relOffset } = await this.getBufferView(
            bufferView.byteOffset || 0,
            bufferView.byteLength,
            5121
          );
          const imageBytes = new Uint8Array(buffer, relOffset, bufferView.byteLength);
          const blob = new Blob([imageBytes], { type: image.mimeType });
          const url = URL.createObjectURL(blob);
          const texture = await this.textureLoader.loadAsync(url);
          URL.revokeObjectURL(url);
          return texture;
        }
      } catch {
        console.warn(`DynamicLoader: Error loading texture ${textureName}`);
      }
    };

    const texturePromises = [];
    for (let i = 0; i < this.json.textures.length; i++) {
      texturePromises.push(
        loadTexture(this.json.textures[i].source).then((texture) => {
          if (texture) {
            // Apply sampler settings, mapping WebGL constants to Three.js constants
            const samplerDef =
              this.json.textures[i].sampler !== undefined && this.json.samplers
                ? this.json.samplers[this.json.textures[i].sampler]
                : {};

            texture.magFilter = GL_TO_THREE_FILTER[samplerDef.magFilter] || LinearFilter;
            texture.minFilter = GL_TO_THREE_FILTER[samplerDef.minFilter] || LinearMipmapLinearFilter;

            // glTF spec: default wrap is REPEAT (10497) when not specified
            texture.wrapS = GL_TO_THREE_WRAP[samplerDef.wrapS] || RepeatWrapping;
            texture.wrapT = GL_TO_THREE_WRAP[samplerDef.wrapT] || RepeatWrapping;

            // glTF textures use non-flipped Y convention
            texture.flipY = false;
            texture.needsUpdate = true;
          }
          this.textureCache.set(i, texture);
        })
      );
    }
    await this.flushBufferRequests();
    await Promise.all(texturePromises);
  }

  loadMaterials() {
    if (!this.json.materials) return this.materials;

    for (let i = 0; i < this.json.materials.length; i++) {
      const materialDef = this.json.materials[i];

      const materialCacheKey = `material_${i}`;
      this.materialCache.set(materialCacheKey, {
        mesh: this.createMaterial(materialDef, GL_CONSTANTS.TRIANGLES),
        points: this.createMaterial(materialDef, GL_CONSTANTS.POINTS),
        lines: this.createMaterial(materialDef, GL_CONSTANTS.LINES),
      });

      this.materialCache.get(materialCacheKey).mesh.name = materialDef.name;
      this.materialCache.get(materialCacheKey).points.name = materialDef.name;
      this.materialCache.get(materialCacheKey).lines.name = materialDef.name;

      this.materials.set(i, this.materialCache.get(materialCacheKey).mesh);
    }
    return this.materials;
  }

  createMaterial(materialDef, primitiveMode = undefined) {
    const params = {};

    if (materialDef.pbrMetallicRoughness) {
      const pbr = materialDef.pbrMetallicRoughness;
      if (pbr.baseColorFactor) {
        params.color = new Color().fromArray(pbr.baseColorFactor);
        params.opacity = pbr.baseColorFactor[3];
        if (params.opacity < 1.0) params.transparent = true;
      }

      if (pbr.baseColorTexture !== undefined) {
        const texture = this.textureCache.get(pbr.baseColorTexture.index);
        if (texture) {
          params.map = texture;
          // Set color to white if map is present and baseColorFactor is not,
          // or else texture will be tinted with default color
          if (!pbr.baseColorFactor) {
            params.color = new Color(0xffffff);
          }
        }
      }
    }

    if (materialDef.emissiveFactor) {
      params.emissive = new Color().fromArray(materialDef.emissiveFactor);
    }

    if (materialDef.alphaMode === "BLEND") {
      params.transparent = true;
    }

    if (materialDef.doubleSided) {
      params.side = DoubleSide;
    }
    let material;

    if (primitiveMode === GL_CONSTANTS.POINTS) {
      params.sizeAttenuation = false;
      material = new PointsMaterial(params);
      material.sizeAttenuation = false;
    } else if (
      primitiveMode === GL_CONSTANTS.LINES ||
      primitiveMode === GL_CONSTANTS.LINE_STRIP ||
      primitiveMode === GL_CONSTANTS.LINE_LOOP
    ) {
      material = new LineBasicMaterial(params);
    } else {
      params.specular = 0x222222;
      params.shininess = 10;
      params.reflectivity = 0.05;
      params.polygonOffset = true;
      params.polygonOffsetFactor = 1;
      params.polygonOffsetUnits = 1;

      // Ensure color is white if only map is present so it renders the texture properly
      if (params.map && !materialDef.pbrMetallicRoughness?.baseColorFactor) {
        params.color = new Color(0xffffff);
      }

      if (materialDef.normalTexture !== undefined) {
        const normalMap = this.textureCache.get(materialDef.normalTexture.index);
        if (normalMap) {
          params.normalMap = normalMap;
        }
      }

      // Emissive texture
      if (materialDef.emissiveTexture !== undefined) {
        const emissiveMap = this.textureCache.get(materialDef.emissiveTexture.index);
        if (emissiveMap) {
          params.emissiveMap = emissiveMap;
        }
      }

      // Metallic-roughness texture
      // Check if we need MeshStandardMaterial for full PBR support or we can use MeshPhongMaterial
      // Use MeshStandardMaterial only if there are PBR textures present to optimize rendering performance.
      // If only factors are present, MeshPhongMaterial is enough for basic lighting.
      const usePBR = materialDef.pbrMetallicRoughness?.metallicRoughnessTexture !== undefined;

      if (usePBR) {
        // Apply default values if not specified
        params.metalness =
          materialDef.pbrMetallicRoughness?.metallicFactor !== undefined
            ? materialDef.pbrMetallicRoughness.metallicFactor
            : 1.0;
        params.roughness =
          materialDef.pbrMetallicRoughness?.roughnessFactor !== undefined
            ? materialDef.pbrMetallicRoughness.roughnessFactor
            : 1.0;

        if (materialDef.pbrMetallicRoughness?.metallicRoughnessTexture !== undefined) {
          const map = this.textureCache.get(materialDef.pbrMetallicRoughness.metallicRoughnessTexture.index);
          if (map) {
            params.metalnessMap = map;
            params.roughnessMap = map;
          }
        }
        material = new MeshStandardMaterial(params);
      } else {
        material = new MeshPhongMaterial(params);
      }

      // Additional maps setup (needs to happen after instantiation for some properties)
      if (material.map) material.map.colorSpace = SRGBColorSpace;
      if (material.emissiveMap) material.emissiveMap.colorSpace = SRGBColorSpace;

      if (material.normalMap) {
        if (materialDef.normalTexture?.scale !== undefined) {
          material.normalScale.set(materialDef.normalTexture.scale, materialDef.normalTexture.scale);
        }
      }
      if (materialDef.occlusionTexture !== undefined) {
        const aoMap = this.textureCache.get(materialDef.occlusionTexture.index);
        if (aoMap) {
          material.aoMap = aoMap;
          if (materialDef.occlusionTexture.strength !== undefined) {
            material.aoMapIntensity = materialDef.occlusionTexture.strength;
          }
        }
      }
    }

    return material;
  }

  getCachedMaterial(materialIndex, primitiveMode) {
    const materialCacheKey = `material_${materialIndex}`;
    const materialCache = this.materialCache.get(materialCacheKey);

    if (materialCache) {
      if (primitiveMode === GL_CONSTANTS.POINTS) {
        return materialCache.points;
      } else if (
        primitiveMode === GL_CONSTANTS.LINES ||
        primitiveMode === GL_CONSTANTS.LINE_STRIP ||
        primitiveMode === GL_CONSTANTS.LINE_LOOP
      ) {
        return materialCache.lines;
      } else {
        return materialCache.mesh;
      }
    }

    return null;
  }

  disposeMaterials() {
    this.textureCache.forEach((texture) => texture.dispose());
    this.textureCache.clear();

    this.materials.forEach((material) => {
      if (material.map) material.map.dispose();
      if (material.lightMap) material.lightMap.dispose();
      if (material.bumpMap) material.bumpMap.dispose();
      if (material.normalMap) material.normalMap.dispose();
      if (material.specularMap) material.specularMap.dispose();
      if (material.envMap) material.envMap.dispose();
      if (material.aoMap) material.aoMap.dispose();
      if (material.metalnessMap) material.metalnessMap.dispose();
      if (material.roughnessMap) material.roughnessMap.dispose();
      if (material.emissiveMap) material.emissiveMap.dispose();
      material.dispose();
    });
    this.materials.clear();

    this.materialCache.forEach((materialCache) => {
      if (materialCache.mesh) {
        if (materialCache.mesh.map) materialCache.mesh.map.dispose();
        if (materialCache.mesh.lightMap) materialCache.mesh.lightMap.dispose();
        if (materialCache.mesh.bumpMap) materialCache.mesh.bumpMap.dispose();
        if (materialCache.mesh.normalMap) materialCache.mesh.normalMap.dispose();
        if (materialCache.mesh.specularMap) materialCache.mesh.specularMap.dispose();
        if (materialCache.mesh.envMap) materialCache.mesh.envMap.dispose();
        if (materialCache.mesh.aoMap) materialCache.mesh.aoMap.dispose();
        if (materialCache.mesh.metalnessMap) materialCache.mesh.metalnessMap.dispose();
        if (materialCache.mesh.roughnessMap) materialCache.mesh.roughnessMap.dispose();
        if (materialCache.mesh.emissiveMap) materialCache.mesh.emissiveMap.dispose();
        materialCache.mesh.dispose();
      }

      if (materialCache.points) {
        if (materialCache.points.map) materialCache.points.map.dispose();
        materialCache.points.dispose();
      }

      if (materialCache.lines) {
        if (materialCache.lines.map) materialCache.lines.map.dispose();
        materialCache.lines.dispose();
      }
    });
    this.materialCache.clear();
  }

  estimateNodeSize(meshIndex) {
    if (!this.json.meshes) return 0;

    const meshDef = this.json.meshes[meshIndex];
    if (!meshDef || !meshDef.primitives) return 0;

    let totalSize = 0;
    for (const primitive of meshDef.primitives) {
      if (primitive.attributes) {
        for (const [, accessorIndex] of Object.entries(primitive.attributes)) {
          if (accessorIndex === undefined) continue;

          const accessor = this.json.accessors[accessorIndex];
          if (!accessor) continue;

          const numComponents = this.getNumComponents(accessor.type);
          const bytesPerComponent = this.getComponentSize(accessor.componentType);
          totalSize += accessor.count * numComponents * bytesPerComponent;
        }
      }

      if (primitive.indices !== undefined) {
        const accessor = this.json.accessors[primitive.indices];
        if (accessor) {
          const bytesPerComponent = this.getComponentSize(accessor.componentType);
          totalSize += accessor.count * bytesPerComponent;
        }
      }
    }

    return totalSize;
  }
}
