import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { JobService } from '../services/job.service.js';
import { DbService } from '../services/db.service.js';

const ENRICHMENT_SETTING_KEYS = [
  'enrich_metadata',
  'enrich_artist_images',
  'enrich_cover_art',
  'enrich_genres',
  'enrich_lyrics',
  'enrich_artist_similarity',
  'enrich_audio_analysis',
] as const;

type ReenrichmentScope = {
  metadata?: boolean;
  artistImages?: boolean;
  albumCovers?: boolean;
};

type ReenrichmentRequest = {
  /** Preview by default. Jobs are only added with an explicit confirmation. */
  confirm?: boolean;
  /** Missing-only is the safe default; false means refresh the selected scope. */
  missingOnly?: boolean;
  /** Per-scope batch cap. Defaults to 250 and never exceeds 1,000. */
  limit?: number;
  scope?: ReenrichmentScope;
};

function getReenrichmentOptions(body: ReenrichmentRequest = {}) {
  const requestedScope = body.scope ?? {};
  const scope = {
    metadata: requestedScope.metadata ?? true,
    artistImages: requestedScope.artistImages ?? true,
    albumCovers: requestedScope.albumCovers ?? true,
  };
  const rawLimit = Number(body.limit ?? 250);
  const limit = Number.isInteger(rawLimit) ? Math.max(1, Math.min(rawLimit, 1000)) : 250;
  return { confirm: body.confirm === true, missingOnly: body.missingOnly !== false, limit, scope };
}

async function getReenrichmentStatus(dbService: DbService, jobService: JobService) {
  const db = dbService.pgClient;
  const [settingsRes, coverageRes, queue] = await Promise.all([
    db.query<{ key: string; value: string }>(
      `SELECT key, value FROM settings WHERE key = ANY($1::text[])`,
      [ENRICHMENT_SETTING_KEYS],
    ),
    db.query<{
      library_tracks: number;
      tracks_without_release_date: number;
      tracks_without_album_mbid: number;
      artists_total: number;
      artists_without_image: number;
      artists_without_mbid: number;
      albums_total: number;
      albums_without_artwork: number;
      albums_without_release_date: number;
    }>(`
      SELECT
        COUNT(*) FILTER (WHERE t.state = 'LIBRARY')::int AS library_tracks,
        COUNT(*) FILTER (WHERE t.state = 'LIBRARY' AND t.release_date IS NULL)::int AS tracks_without_release_date,
        COUNT(*) FILTER (WHERE t.state = 'LIBRARY' AND al.mbid IS NULL)::int AS tracks_without_album_mbid,
        (SELECT COUNT(*)::int FROM artists) AS artists_total,
        (SELECT COUNT(*)::int FROM artists WHERE image_path IS NULL OR image_path = '') AS artists_without_image,
        (SELECT COUNT(*)::int FROM artists WHERE mbid IS NULL) AS artists_without_mbid,
        (SELECT COUNT(*)::int FROM albums) AS albums_total,
        (SELECT COUNT(*)::int FROM albums WHERE artwork_id IS NULL OR artwork_id = '') AS albums_without_artwork,
        (SELECT COUNT(*)::int FROM albums WHERE release_date IS NULL) AS albums_without_release_date
      FROM tracks t
      LEFT JOIN albums al ON al.id = t.album_id
    `),
    jobService.getEnrichmentQueueDiagnostics(),
  ]);
  const settings = Object.fromEntries(
    ENRICHMENT_SETTING_KEYS.map((key) => [
      key,
      settingsRes.rows.find((row) => row.key === key)?.value === 'true',
    ]),
  ) as Record<(typeof ENRICHMENT_SETTING_KEYS)[number], boolean>;

  return { settings, coverage: coverageRes.rows[0], queue };
}

export default async function adminRoutes(fastify: FastifyInstance, options: { jobService: JobService; dbService: DbService }) {
  const { jobService, dbService } = options;

  fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
    const { directory } = request.body as { directory: string };
    if (!directory) {
      return reply.code(400).send({ error: 'Directory is required' });
    }
    await jobService.enqueueLibraryScan(directory);
    return { status: 'Scan job enqueued', directory };
  });

  fastify.post('/reindex-tracks', async (_request: FastifyRequest, reply: FastifyReply) => {
    await jobService.enqueueReindexTracks();
    return { status: 'Reindex job enqueued' };
  });

  fastify.post('/reprocess-artists', async (_request: FastifyRequest, reply: FastifyReply) => {
    await jobService.enqueueReprocessArtists();
    return { status: 'Artist reprocessing job enqueued' };
  });

  /** Rebuild artist/genre/audio beliefs from durable history after a model upgrade. */
  fastify.post('/vibe/rebuild-beliefs', async (request: FastifyRequest, reply: FastifyReply) => {
    const { userId } = (request.body ?? {}) as { userId?: string };
    const resolvedUserId = userId || (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
    const result = await dbService.rebuildDerivedListenerBeliefs(resolvedUserId);
    return reply.send({ status: 'rebuilt', userId: resolvedUserId, ...result });
  });

  /**
   * Attach a human/resolver-vetted source to a System E candidate. This is an
   * admin-only hand-off: graph traversal identifies an artist/path, but must
   * never turn that into an arbitrary web search and download. The worker still
   * applies its own host allow-list immediately before invoking yt-dlp.
   */
  fastify.post('/discovery/candidates/:id/acquisition-source', async (request, reply) => {
    const { id } = request.params as { id: string };
    const body = request.body as { url?: string; expectedTitle?: string; expectedArtist?: string };
    if (!body?.url || typeof body.url !== 'string' || body.url.length > 4000) {
      return reply.code(400).send({ error: 'HTTPS url is required' });
    }
    let url: URL;
    try {
      url = new URL(body.url);
    } catch {
      return reply.code(400).send({ error: 'url is invalid' });
    }
    if (url.protocol !== 'https:' || url.username || url.password) {
      return reply.code(400).send({ error: 'url must be credential-free HTTPS' });
    }
    const source = {
      url: url.toString(),
      ...(typeof body.expectedTitle === 'string' ? { expectedTitle: body.expectedTitle.slice(0, 500) } : {}),
      ...(typeof body.expectedArtist === 'string' ? { expectedArtist: body.expectedArtist.slice(0, 500) } : {}),
    };
    try {
      const result = await dbService.pgClient.query(
        `UPDATE discovery_candidates
         SET notes = jsonb_set(COALESCE(notes, '{}'::jsonb), '{acquisition}', $2::jsonb, true),
             status = 'candidate', last_eval_at = NULL, last_error = NULL
         WHERE id = $1::uuid
           AND status IN ('candidate', 'awaiting_resolution', 'acquisition_disabled', 'failed')
         RETURNING id, status`,
        [id, JSON.stringify(source)]
      );
      if (result.rows.length === 0) {
        return reply.code(404).send({ error: 'candidate does not exist or cannot be re-queued' });
      }
      return reply.send({ candidate: result.rows[0] });
    } catch (err) {
      const message = err instanceof Error ? err.message : 'could not attach acquisition source';
      return reply.code(400).send({ error: message });
    }
  });

  fastify.post('/dedup-albums', async (_request: FastifyRequest, reply: FastifyReply) => {
    // Merge duplicate album rows directly (synchronous — it's just SQL, no
    // external API calls). Returns the number of albums merged away.
    // Tiebreaker for keeper selection: MBID > artwork > earliest release_date
    // > most tracks > oldest created_at.
    //
    // Note: the two duplicate-detection passes (by title and by MBID) may find
    // overlapping pairs; the UNION ALL in `pairs` can produce duplicates, but
    // the DELETE at the end is idempotent (a loser deleted in one pair won't
    // exist for the next). The folded/moved CTEs also tolerate this because
    // COALESCE is idempotent and the loser row simply won't be found again.
    const res = await dbService.pgClient.query<{ count: number }>(`
      WITH duplicates AS (
        SELECT lower(title) AS lt, array_agg(id ORDER BY
          CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
          CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END,
          release_date NULLS LAST,
          (SELECT COUNT(*) FROM tracks t WHERE t.album_id = albums.id) DESC,
          created_at
        ) AS ids
        FROM albums GROUP BY lower(title) HAVING COUNT(*) > 1
      ),
      mbid_dupes AS (
        SELECT mbid, array_agg(id ORDER BY
          CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END,
          release_date NULLS LAST,
          created_at
        ) AS ids
        FROM albums WHERE mbid IS NOT NULL
        GROUP BY mbid HAVING COUNT(*) > 1
      ),
      pairs AS (
        SELECT ids[1] AS keep_id, unnest(ids[2:]) AS loser_id FROM duplicates
        UNION
        SELECT ids[1] AS keep_id, unnest(ids[2:]) AS loser_id FROM mbid_dupes
      ),
      -- Fold metadata from losers onto keepers (idempotent via COALESCE).
      folded AS (
        UPDATE albums a SET
          artwork_id = COALESCE(a.artwork_id, src.artwork_id),
          year = COALESCE(a.year, src.year),
          mbid = COALESCE(a.mbid, src.mbid),
          release_date = COALESCE(a.release_date, src.release_date)
        FROM (
          SELECT DISTINCT ON (p.loser_id) p.keep_id, lo.artwork_id, lo.year, lo.mbid, lo.release_date, p.loser_id
          FROM pairs p
          JOIN albums lo ON lo.id = p.loser_id
          ORDER BY p.loser_id
        ) AS src
        WHERE a.id = src.keep_id
      ),
      -- Move tracks from losers to keepers.
      moved AS (
        UPDATE tracks SET album_id = src.keep_id
        FROM (SELECT DISTINCT keep_id, loser_id FROM pairs) AS src
        WHERE tracks.album_id = src.loser_id
      ),
      -- Delete losers.
      deleted AS (
        DELETE FROM albums
        WHERE id IN (SELECT DISTINCT loser_id FROM pairs)
        RETURNING 1
      )
      SELECT COUNT(*)::int AS count FROM deleted
    `);
    return { status: 'Albums deduplicated', merged: res.rows[0]?.count ?? 0 };
  });

  /**
   * Return coverage, active toggles, queued work, and recent worker failures.
   * This is deliberately separate from enqueueing so an operator can diagnose
   * a disabled provider or a failing worker before launching another batch.
   */
  fastify.get('/reenrichment/status', async () => {
    return getReenrichmentStatus(dbService, jobService);
  });

  /**
   * Safe re-enrichment control plane. A call is a preview unless confirm=true;
   * the default is a 250-entity, missing-only batch. The three job types remain
   * independent so artwork work is not hidden behind track metadata work.
   */
  fastify.post<{ Body: ReenrichmentRequest }>('/reenrichment', async (request, reply) => {
    const options = getReenrichmentOptions(request.body);
    const status = await getReenrichmentStatus(dbService, jobService);
    const blockedBySettings: Partial<Record<keyof ReenrichmentScope, string>> = {};
    const metadataEnabled = status.settings.enrich_metadata
      || status.settings.enrich_genres
      || status.settings.enrich_lyrics;
    if (options.scope.metadata && !metadataEnabled) {
      blockedBySettings.metadata = 'All track enrichment toggles are disabled.';
    }
    if (options.scope.artistImages && !status.settings.enrich_artist_images) {
      blockedBySettings.artistImages = 'enrich_artist_images is disabled.';
    }
    if (options.scope.albumCovers && !status.settings.enrich_cover_art) {
      blockedBySettings.albumCovers = 'enrich_cover_art is disabled.';
    }

    const db = dbService.pgClient;
    const [tracks, artists, albums] = await Promise.all([
      options.scope.metadata && !blockedBySettings.metadata
        ? db.query<{ id: string }>(
            `SELECT t.id
             FROM tracks t
             LEFT JOIN albums al ON al.id = t.album_id
             LEFT JOIN track_artists ta ON ta.track_id = t.id AND ta.role = 'main'
             LEFT JOIN artists ar ON ar.id = ta.artist_id
             WHERE t.state = 'LIBRARY'
               AND ($1::boolean = false OR t.release_date IS NULL OR al.mbid IS NULL OR ar.mbid IS NULL)
             ORDER BY t.id
             LIMIT $2`,
            [options.missingOnly, options.limit],
          )
        : Promise.resolve({ rows: [] as Array<{ id: string }> }),
      options.scope.artistImages && !blockedBySettings.artistImages
        ? db.query<{ id: string }>(
            `SELECT id FROM artists
             WHERE $1::boolean = false OR image_path IS NULL OR image_path = ''
             ORDER BY id
             LIMIT $2`,
            [options.missingOnly, options.limit],
          )
        : Promise.resolve({ rows: [] as Array<{ id: string }> }),
      options.scope.albumCovers && !blockedBySettings.albumCovers
        ? db.query<{ id: string }>(
            `SELECT id FROM albums
             WHERE $1::boolean = false OR artwork_id IS NULL OR artwork_id = ''
             ORDER BY id
             LIMIT $2`,
            [options.missingOnly, options.limit],
          )
        : Promise.resolve({ rows: [] as Array<{ id: string }> }),
    ]);

    const plan = {
      metadataTrackIds: tracks.rows.map((row) => row.id),
      artistIds: artists.rows.map((row) => row.id),
      albumIds: albums.rows.map((row) => row.id),
    };
    const wouldQueue = {
      metadata: plan.metadataTrackIds.length,
      artistImages: plan.artistIds.length,
      albumCovers: plan.albumIds.length,
    };

    if (!options.confirm) {
      return {
        status: 'preview',
        message: 'No jobs were queued. Repeat with confirm=true to enqueue this bounded plan.',
        options,
        blockedBySettings,
        wouldQueue,
        diagnostics: status,
      };
    }

    if (Object.keys(blockedBySettings).length === 3 ||
        (wouldQueue.metadata + wouldQueue.artistImages + wouldQueue.albumCovers === 0)) {
      return reply.code(409).send({
        status: 'not_queued',
        message: Object.keys(blockedBySettings).length === 3
          ? 'Every selected scope is disabled by enrichment settings.'
          : 'No eligible entities matched this re-enrichment batch.',
        options,
        blockedBySettings,
        wouldQueue,
        diagnostics: status,
      });
    }

    const queued = await jobService.enqueueReenrichment(plan);
    const enqueueFailures = queued.metadata.failed.length
      + queued.artistImages.failed.length
      + queued.albumCovers.failed.length;
    return {
      status: enqueueFailures === 0 ? 'queued' : 'partially_queued',
      message: enqueueFailures === 0
        ? 'Re-enrichment jobs were queued. Check /reenrichment/status and job history for outcomes.'
        : 'Some jobs could not be queued; see queued.*.failed for exact errors.',
      options,
      blockedBySettings,
      queued,
      diagnostics: await getReenrichmentStatus(dbService, jobService),
    };
  });

  // The prior endpoint queued every track and implied cover/image work that it
  // never scheduled. Keep the failure explicit instead of silently claiming a
  // successful library-wide refresh.
  fastify.post('/reenrich-tracks', async (_request: FastifyRequest, reply: FastifyReply) => {
    return reply.code(410).send({
      error: 'Deprecated endpoint. Use POST /admin/reenrichment (preview first; confirm=true to queue).',
    });
  });

  fastify.get('/queue-stats', async () => {
    return await jobService.getQueueStats();
  });

  fastify.get('/job-history', async (request: FastifyRequest) => {
    const { limit } = request.query as { limit?: string };
    return await jobService.getJobHistory(parseInt(limit || '100', 10));
  });

  fastify.get('/duplicates', async (request) => {
    const { mode } = request.query as { mode?: string };
    return await dbService.getDuplicateGroups(mode === 'title-artist' ? 'title-artist' : 'hash');
  });

  fastify.post('/duplicates/merge', async (request: FastifyRequest, reply: FastifyReply) => {
    const { keepId, deleteIds } = request.body as { keepId: string; deleteIds: string[] };
    if (!keepId || !Array.isArray(deleteIds) || deleteIds.length === 0) {
      return reply.code(400).send({ error: 'keepId and deleteIds[] are required' });
    }
    await dbService.mergeDuplicates(keepId, deleteIds);
    return { status: 'merged', kept: keepId, deleted: deleteIds.length };
  });

  fastify.get('/artist-stats', async (request: FastifyRequest, reply: FastifyReply) => {
    const db = dbService.pgClient;
    
    const total = await db.query('SELECT COUNT(*)::int AS n FROM artists');
    const withMbid = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE mbid IS NOT NULL');
    const withCanonical = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE canonical_name IS NOT NULL');
    const withSort = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE sort_name IS NOT NULL');
    const withImage = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE image_path IS NOT NULL AND image_path != \'\'');
    const aliases = await db.query('SELECT COUNT(*)::int AS n FROM artist_aliases');
    const cache = await db.query('SELECT COUNT(*)::int AS n FROM artist_lookup_cache');
    
    const noImage = await db.query(`
      SELECT name, canonical_name, mbid, sort_name 
      FROM artists 
      WHERE image_path IS NULL OR image_path = ''
      ORDER BY name
      LIMIT 50
    `);

    return {
      total: total.rows[0].n,
      withMbid: withMbid.rows[0].n,
      withCanonicalName: withCanonical.rows[0].n,
      withSortName: withSort.rows[0].n,
      withImage: withImage.rows[0].n,
      withoutImage: total.rows[0].n - withImage.rows[0].n,
      aliases: aliases.rows[0].n,
      cacheSize: cache.rows[0].n,
      imageCoverage: `${((withImage.rows[0].n / total.rows[0].n) * 100).toFixed(1)}%`,
      artistsWithoutImage: noImage.rows,
    };
  });

  fastify.get('/artist-verify/:name', async (request: FastifyRequest, reply: FastifyReply) => {
    const { name } = request.params as { name: string };
    const db = dbService.pgClient;
    
    const exact = await db.query(
      `SELECT id, name, canonical_name, sort_name, mbid, image_path 
       FROM artists WHERE name = $1`,
      [name]
    );
    
    const normalized = await db.query(
      `SELECT id, name, canonical_name, sort_name, mbid, image_path 
       FROM artists WHERE normalize_artist(name) = normalize_artist($1)`,
      [name]
    );
    
    const aliases = await db.query(
      `SELECT a.*, ar.canonical_name as artist_canonical, ar.mbid as artist_mbid
       FROM artist_aliases a
       JOIN artists ar ON ar.id = a.artist_id
       WHERE a.alias_normalized = normalize_artist($1)`,
      [name]
    );

    return {
      exactMatch: exact.rows,
      normalizedMatches: normalized.rows,
      aliases: aliases.rows,
    };
  });
}
