// Covers the .fscene JSON encoding: canonical write, tolerant (JSONC) read,
// round-trip fidelity, the version/migration framework, and feature gating.

import 'dart:math';

import 'package:scene/scene.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:vector_math/vector_math.dart';

// Builds a small but representative document deterministically.
SceneDocument _sampleDocument() {
  final doc = SceneDocument(
    documentId: DocumentId.generate(Random(99)),
    allocator: IdAllocator(session: 7),
  );
  doc.generator = 'test';
  doc.featuresRequired.add('skinning');
  final stageEnv = doc.addResource(
    EnvironmentResource(
      doc.newId(),
      exposure: 2.5,
      toneMapping: 'pbrNeutral',
      environment: const AssetEnvironment(AssetRef('assets/env.png')),
    ),
  );
  doc.stage.environmentRef = stageEnv.id;

  final payload = doc.addPayload(
    PayloadSpec(
      doc.newId(),
      encoding: PayloadEncoding.vertexBuffer,
      layout: 'unskinned',
      length: 480,
    ),
  );
  final indexPayload = doc.addPayload(
    PayloadSpec(
      doc.newId(),
      encoding: PayloadEncoding.indexBuffer,
      format: 'uint16',
      length: 36,
    ),
  );
  final geo = doc.addResource(
    GeometryResource(
      doc.newId(),
      vertices: payload.id,
      indices: indexPayload.id,
      bounds: BoundsSpec(min: Vector3(-1, -1, -1), max: Vector3(1, 1, 1)),
    ),
  );
  final imagePayload = doc.addPayload(
    PayloadSpec(
      doc.newId(),
      encoding: PayloadEncoding.image,
      format: 'rgba8',
      width: 2,
      height: 2,
      length: 16,
    ),
  );
  final albedo = doc.addResource(
    TextureResource(doc.newId(), payload: imagePayload.id),
  );
  final mat = doc.addResource(
    MaterialResource(
      doc.newId(),
      type: 'physicallyBased',
      properties: {
        'baseColor': const ColorValue(1, 0.5, 0.25, 1),
        'metallic': const DoubleValue(0.0),
        'baseColorTexture': ResourceRefValue(albedo.id),
      },
    ),
  );

  final root = doc.createNode(name: 'root', root: true);
  root.transform = TrsTransform(translation: Vector3(0, 1, 0));
  final car = doc.createNode(
    name: 'Car',
    components: [
      ComponentSpec(
        'mesh',
        properties: {
          'geometry': ResourceRefValue(geo.id),
          'material': ResourceRefValue(mat.id),
        },
      ),
    ],
    layers: 3,
  );
  root.children.add(car.id);
  return doc;
}

// Compares two documents structurally enough to catch round-trip loss.
void _expectSameStructure(SceneDocument a, SceneDocument b) {
  expect(b.documentId, a.documentId);
  expect(b.formatVersion, a.formatVersion);
  expect(b.generator, a.generator);
  expect(b.featuresRequired, a.featuresRequired);
  expect(b.stage.environmentRef, a.stage.environmentRef);
  final envA = a.resources[a.stage.environmentRef!]! as EnvironmentResource;
  final envB = b.resources[b.stage.environmentRef!]! as EnvironmentResource;
  expect(envB.exposure, envA.exposure);
  expect(envB.environment, isA<AssetEnvironment>());
  expect(b.nodes.keys.toSet(), a.nodes.keys.toSet());
  expect(b.roots, a.roots);
  expect(b.resources.keys.toSet(), a.resources.keys.toSet());
  expect(b.payloads.keys.toSet(), a.payloads.keys.toSet());

  for (final id in a.nodes.keys) {
    final an = a.nodes[id]!;
    final bn = b.nodes[id]!;
    expect(bn.name, an.name);
    expect(bn.layers, an.layers);
    expect(bn.children, an.children);
    expect(bn.components.length, an.components.length);
    expect(bn.transform.toMatrix4(), an.transform.toMatrix4());
  }
}

void main() {
  test('constant diffuse environment round-trips', () {
    final document = SceneDocument();
    final environment = document.addResource(
      EnvironmentResource(
        document.newId(),
        environment: ConstantEnvironment(Vector3(0.2, 0.3, 0.4)),
      ),
    );
    document.stage.environmentRef = environment.id;

    final decoded = readFscene(writeFscene(document));
    final decodedResource =
        decoded.resource(decoded.stage.environmentRef!)! as EnvironmentResource;
    final constant = decodedResource.environment as ConstantEnvironment;

    expect(constant.color, Vector3(0.2, 0.3, 0.4));
  });

  group('canonicalJson', () {
    test('inlines number arrays and rejects non-finite numbers', () {
      final out = canonicalJson({
        'v': [1.0, 2.0, 3.0],
        'n': 5,
      });
      expect(out, contains('[1.0, 2.0, 3.0]'));
      expect(out.endsWith('\n'), isTrue);
      expect(
        () => canonicalJson(double.nan),
        throwsA(isA<FsceneEncodeException>()),
      );
      expect(
        () => canonicalJson(double.infinity),
        throwsA(isA<FsceneEncodeException>()),
      );
    });

    test('normalizes negative zero', () {
      expect(canonicalJson(-0.0).trim(), '0.0');
    });
  });

  group('stripJsonc', () {
    test('removes comments and trailing commas, keeping string contents', () {
      const src = '''
{
  // a line comment
  "a": 1, /* block */
  "b": "http://x/y, // not a comment",
  "c": [1, 2,],
}
''';
      final stripped = stripJsonc(src);
      expect(stripped, isNot(contains('line comment')));
      expect(stripped, isNot(contains('block')));
      expect(stripped, contains('http://x/y, // not a comment'));
      expect(stripped.replaceAll(RegExp(r'\s'), ''), isNot(contains(',}')));
      expect(stripped.replaceAll(RegExp(r'\s'), ''), isNot(contains(',]')));
    });
  });

  group('property values', () {
    test('every variant round-trips through JSON', () {
      String token(LocalId id) => 'r:${id.toToken()}';
      final values = <PropertyValue>[
        const BoolValue(true),
        const IntValue(-7),
        const DoubleValue(1.5),
        const StringValue('hi'),
        Vec3Value(Vector3(1, 2, 3)),
        QuaternionValue(Quaternion(0, 0, 0, 1)),
        Matrix4Value(Matrix4.identity()),
        const ColorValue(1, 0, 0, 1),
        const ResourceRefValue(LocalId(5, 9)),
        const NodeRefValue(LocalId(5, 10)),
        ListValue([const IntValue(1), const StringValue('x')]),
        MapValue({'k': const BoolValue(false)}),
      ];
      for (final v in values) {
        final decoded = decodePropertyValue(encodePropertyValue(v, token));
        expect(decoded.runtimeType, v.runtimeType);
      }
      // Spot-check a reference's id survives the prefix round-trip.
      final ref = decodePropertyValue(
        encodePropertyValue(const ResourceRefValue(LocalId(5, 9)), token),
      );
      expect((ref as ResourceRefValue).id, const LocalId(5, 9));
    });
  });

  group('document round-trip', () {
    test('write then read reproduces the document', () {
      final doc = _sampleDocument();
      final text = writeFscene(doc);
      final back = readFscene(text);
      _expectSameStructure(doc, back);
    });

    test('canonical write is stable across two passes', () {
      final doc = _sampleDocument();
      final first = writeFscene(doc);
      final second = writeFscene(readFscene(first));
      expect(second, first);
    });

    test('reads through a JSONC superset', () {
      final text = writeFscene(_sampleDocument());
      final loose =
          '// header comment\n${text.replaceFirst('{', '{\n  /* note */')}';
      expect(() => readFscene(loose), returnsNormally);
    });

    test('ignores unknown fields', () {
      final text = writeFscene(_sampleDocument());
      final withExtra = text.replaceFirst('{', '{\n  "futureField": 123,');
      expect(() => readFscene(withExtra), returnsNormally);
    });
  });

  group('versioning', () {
    test('refuses a newer-than-supported version', () {
      final text = writeFscene(
        _sampleDocument(),
      ).replaceFirst('"fscene": 1', '"fscene": 999');
      expect(() => readFscene(text), throwsA(isA<FsceneVersionException>()));
    });

    test('runs the migration chain to the current version', () {
      // A version-0 document plus a single v0 -> v1 migration step.
      final v1Text = writeFscene(_sampleDocument());
      final v0Text = v1Text.replaceFirst('"fscene": 1', '"fscene": 0');
      // Without a migration, version 0 cannot load.
      expect(() => readFscene(v0Text), throwsA(isA<FsceneVersionException>()));
      // With one, it migrates and loads.
      final migrated = readFscene(v0Text, migrations: [(json) => json]);
      expect(migrated.formatVersion, currentFsceneVersion);
    });

    test('refuses an unsupported required feature', () {
      final doc = _sampleDocument();
      doc.featuresRequired.add('timeTravel');
      final text = writeFscene(doc);
      expect(
        () => readFscene(text),
        throwsA(isA<FsceneUnsupportedFeatureException>()),
      );
    });
  });

  group('procedural geometry', () {
    test('procedural geometry resources round-trip through JSON', () {
      final doc = SceneDocument(
        documentId: DocumentId.generate(Random(1)),
        allocator: IdAllocator(session: 2),
      );
      doc.createNode(name: 'root', root: true);
      final cuboid = doc.addResource(
        GeometryResource(
          doc.newId(),
          procedural: CuboidGeometrySpec(
            extents: Vector3(2, 1, 0.5),
            debugColors: true,
          ),
        ),
      );
      doc.addResource(
        GeometryResource(
          doc.newId(),
          procedural: PlaneGeometrySpec(width: 4, depth: 4, segmentsZ: 3),
        ),
      );
      doc.addResource(
        GeometryResource(
          doc.newId(),
          procedural: SphereGeometrySpec(radius: 0.7, segments: 12, rings: 6),
        ),
      );
      doc.addResource(
        GeometryResource(
          doc.newId(),
          procedural: TorusGeometrySpec(
            radius: 2.5,
            tubeRadius: 0.08,
            radialSegments: 48,
            tubularSegments: 10,
          ),
        ),
      );
      doc.addResource(
        GeometryResource(
          doc.newId(),
          procedural: IcosphereGeometrySpec(radius: 1.75, subdivisions: 3),
        ),
      );

      final back = readFscene(writeFscene(doc));
      expect(back.resources, hasLength(5));
      final shape =
          (back.resource(cuboid.id) as GeometryResource).procedural
              as CuboidGeometrySpec;
      expect(shape.extents, Vector3(2, 1, 0.5));
      expect(shape.debugColors, isTrue);
      final torus = back.resources.values
          .whereType<GeometryResource>()
          .map((resource) => resource.procedural)
          .whereType<TorusGeometrySpec>()
          .single;
      expect(torus.radius, 2.5);
      expect(torus.tubeRadius, 0.08);
      expect(torus.radialSegments, 48);
      expect(torus.tubularSegments, 10);
      final icosphere = back.resources.values
          .whereType<GeometryResource>()
          .map((resource) => resource.procedural)
          .whereType<IcosphereGeometrySpec>()
          .single;
      expect(icosphere.radius, 1.75);
      expect(icosphere.subdivisions, 3);
    });

    test('fmat materials and external/encoded textures round-trip', () {
      final doc = SceneDocument();
      final assetTexture = doc.addResource(
        TextureResource(doc.newId(), asset: const AssetRef('assets/x.png')),
      );
      final pngPayload = doc.addPayload(
        PayloadSpec(
          doc.newId(),
          encoding: PayloadEncoding.image,
          format: 'png',
          length: 4,
        ),
      );
      final encodedTexture = doc.addResource(
        TextureResource(doc.newId(), payload: pngPayload.id),
      );
      final fmat = doc.addResource(
        MaterialResource(
          doc.newId(),
          type: 'fmat',
          asset: const AssetRef('materials/toon.fmat'),
          properties: {'baseColorTexture': ResourceRefValue(assetTexture.id)},
        ),
      );

      final back = readFscene(writeFscene(doc));
      expect(
        (back.resource(assetTexture.id) as TextureResource).asset?.key,
        'assets/x.png',
      );
      expect(
        (back.resource(encodedTexture.id) as TextureResource).payload,
        pngPayload.id,
      );
      expect(back.payload(pngPayload.id)?.format, 'png');
      final material = back.resource(fmat.id) as MaterialResource;
      expect(material.type, 'fmat');
      expect(material.asset?.key, 'materials/toon.fmat');
    });

    test('texture content roles round-trip, with color left implicit', () {
      final doc = SceneDocument();
      final color = doc.addResource(
        TextureResource(doc.newId(), asset: const AssetRef('albedo.png')),
      );
      final normal = doc.addResource(
        TextureResource(
          doc.newId(),
          asset: const AssetRef('normal.png'),
          content: 'normal',
        ),
      );

      final json = writeFscene(doc);
      expect(json, isNot(contains('"content": "color"')));

      final back = readFscene(json);
      expect((back.resource(color.id) as TextureResource).content, 'color');
      expect((back.resource(normal.id) as TextureResource).content, 'normal');
    });
  });
}
