import { describe, expect, it } from 'vitest';
import {
  runHDBSCAN,
  runDBSCAN,
  autoSelectParameters,
  calculateClusterQuality,
} from './clustering.js';

// ── Helpers ──────────────────────────────────────────────────────

function makePoint(id: string, embedding: number[], errorType = 'TypeError') {
  return {
    id,
    embedding,
    metadata: {
      errorType,
      messageTemplate: `Error in ${id}`,
      stackSignature: `sig_${id}`,
      productId: 'test',
      firstSeenAt: new Date(Date.now() - Math.random() * 86400000).toISOString(),
    },
  };
}

function makeCluster(center: number[], spread: number, count: number, prefix: string) {
  return Array.from({ length: count }, (_, i) =>
    makePoint(
      `${prefix}_${i}`,
      center.map(c => c + (Math.random() - 0.5) * spread)
    )
  );
}

// ============================================================================
// runHDBSCAN
// ============================================================================

describe('runHDBSCAN', () => {
  it('returns all points as noise when fewer than minClusterSize', () => {
    const points = [makePoint('a', [1, 0, 0]), makePoint('b', [0, 1, 0])];
    const result = runHDBSCAN(points, { minClusterSize: 3 });
    expect(result.clusters).toHaveLength(0);
    expect(result.noise).toHaveLength(2);
    expect(result.labels.get('a')).toBe(-1);
  });

  it('finds clusters in well-separated data', () => {
    const clusterA = makeCluster([1, 0, 0], 0.01, 5, 'a');
    const clusterB = makeCluster([0, 0, 1], 0.01, 5, 'b');
    const points = [...clusterA, ...clusterB];

    const result = runHDBSCAN(points, { minClusterSize: 3, minSamples: 2, metric: 'euclidean' });
    expect(result.clusters.length).toBeGreaterThanOrEqual(1);
    // Every point should get a label
    expect(result.labels.size).toBe(points.length);
  });

  it('assigns centroids to clusters', () => {
    const points = makeCluster([1, 1, 1], 0.01, 6, 'c');
    const result = runHDBSCAN(points, { minClusterSize: 3, metric: 'euclidean' });
    for (const cluster of result.clusters) {
      expect(cluster.centroid.length).toBe(3);
      // Centroid should be near [1,1,1]
      for (const val of cluster.centroid) {
        expect(val).toBeCloseTo(1, 0);
      }
    }
  });

  it('handles single-dimension embeddings', () => {
    const points = [
      makePoint('x1', [0.1]),
      makePoint('x2', [0.2]),
      makePoint('x3', [0.15]),
      makePoint('x4', [0.9]),
      makePoint('x5', [0.95]),
      makePoint('x6', [0.88]),
    ];
    const result = runHDBSCAN(points, { minClusterSize: 2, minSamples: 1, metric: 'euclidean' });
    expect(result.labels.size).toBe(6);
  });
});

// ============================================================================
// runDBSCAN
// ============================================================================

describe('runDBSCAN', () => {
  it('finds dense clusters and marks outliers as noise', () => {
    const cluster = makeCluster([0, 0], 0.05, 5, 'dense');
    const outlier = makePoint('outlier', [10, 10]);
    const points = [...cluster, outlier];

    const result = runDBSCAN(points, { eps: 0.5, minPts: 2 });
    expect(result.clusters.length).toBeGreaterThanOrEqual(1);
    expect(result.labels.get('outlier')).toBe(-1);
  });

  it('returns no clusters when eps is too small', () => {
    const points = makeCluster([0, 0], 1.0, 5, 'spread');
    const result = runDBSCAN(points, { eps: 0.001, minPts: 3 });
    expect(result.clusters).toHaveLength(0);
    expect(result.noise).toHaveLength(5);
  });

  it('puts all points in one cluster when eps is large enough', () => {
    const points = makeCluster([0, 0], 0.1, 6, 'tight');
    const result = runDBSCAN(points, { eps: 100, minPts: 2 });
    expect(result.clusters).toHaveLength(1);
    expect(result.noise).toHaveLength(0);
  });
});

// ============================================================================
// autoSelectParameters
// ============================================================================

describe('autoSelectParameters', () => {
  it('selects DBSCAN for very small datasets (<10)', () => {
    const params = autoSelectParameters(5);
    expect(params.algorithm).toBe('dbscan');
  });

  it('selects HDBSCAN with small minClusterSize for small datasets', () => {
    const params = autoSelectParameters(25);
    expect(params.algorithm).toBe('hdbscan');
    expect(params.minClusterSize).toBe(2);
  });

  it('selects HDBSCAN with medium settings for medium datasets', () => {
    const params = autoSelectParameters(100);
    expect(params.algorithm).toBe('hdbscan');
    expect(params.minClusterSize).toBe(3);
    expect(params.metric).toBe('cosine');
  });

  it('selects HDBSCAN with larger settings for large datasets', () => {
    const params = autoSelectParameters(1000);
    expect(params.algorithm).toBe('hdbscan');
    expect(params.minClusterSize).toBe(5);
    expect(params.minSamples).toBe(3);
  });
});

// ============================================================================
// calculateClusterQuality
// ============================================================================

describe('calculateClusterQuality', () => {
  it('returns metrics object with silhouetteScore', () => {
    const points = [...makeCluster([0, 0], 0.01, 3, 'a'), ...makeCluster([5, 5], 0.01, 3, 'b')];
    const labels = new Map<string, number>();
    points.forEach((p, i) => labels.set(p.id, i < 3 ? 0 : 1));

    const clusters = [
      {
        id: 'c0',
        points: points.slice(0, 3),
        centroid: [0, 0],
        stability: 1,
        density: 1,
      },
      {
        id: 'c1',
        points: points.slice(3),
        centroid: [5, 5],
        stability: 1,
        density: 1,
      },
    ];

    const quality = calculateClusterQuality(points, labels, clusters);
    expect(quality).toHaveProperty('silhouetteScore');
    expect(quality).toHaveProperty('daviesBouldinIndex');
    expect(quality).toHaveProperty('calinskiHarabaszIndex');
    // Well-separated clusters should have positive silhouette
    expect(quality.silhouetteScore).toBeGreaterThan(0);
  });

  it('returns 0 silhouette when all points are noise', () => {
    const points = [makePoint('n1', [0, 0]), makePoint('n2', [1, 1])];
    const labels = new Map<string, number>([
      ['n1', -1],
      ['n2', -1],
    ]);
    const quality = calculateClusterQuality(points, labels, []);
    expect(quality.silhouetteScore).toBe(0);
  });
});
