import { DbService } from './db.service.js';

export interface DiscoveryCandidate {
  id: string;
  source: string;
  externalId: string;
  title: string | null;
  artistCredit: unknown;
  notes: unknown;
  status: string;
  relevance: number;
  explanation: string;
}

export class DiscoveryService {
  constructor(private db: DbService) {}

  // ---------------------------------------------------------------
  // E.1 — Graph exploration: walk the graph beyond the library
  // ---------------------------------------------------------------
  async walkGraphForDiscovery(userId: string): Promise<number> {
    const beliefs = await this.db.getListenerBeliefs({
      userId,
      profile: 'longterm',
      entityType: 'artist',
      dimension: 'affinity',
      limit: 100,
      orderBy: 'value',
      order: 'DESC',
    });

    const highAffinity = beliefs.filter((b) => b.value > 0.3);
    let newCount = 0;

    for (const belief of highAffinity) {
      const candidates = await this.db.pgClient.query<{ candidate_artist_id: string }>(
        `SELECT cf.object_id AS candidate_artist_id
         FROM claim_fusion cf
         WHERE cf.subject_id = $1::uuid
           AND cf.predicate IN ('same_scene_as', 'featured_on')
           AND cf.object_type = 'artist'
           AND NOT EXISTS (
             SELECT 1 FROM tracks t
             JOIN claim_fusion cf2 ON cf2.subject_id = t.id
             WHERE cf2.object_id = cf.object_id
               AND cf2.predicate = 'credited_main_on'
           )
         LIMIT 20`,
        [belief.entity_id]
      );

      for (const row of candidates.rows) {
        const dcRes = await this.db.pgClient.query<{ id: string }>(
          `INSERT INTO discovery_candidates (source, external_id, artist_credit, notes)
           VALUES ($1, $2, $3, $4)
           ON CONFLICT (source, external_id) DO NOTHING
           RETURNING id`,
          [
            'graph_exploration',
            row.candidate_artist_id,
            JSON.stringify([{ artist_id: row.candidate_artist_id }]),
            JSON.stringify({
              discovery_source: 'graph_exploration',
              path: [
                {
                  entity_id: belief.entity_id,
                  predicate: 'affinity_source',
                  profile: 'longterm',
                  affinity: belief.value,
                },
                {
                  entity_id: row.candidate_artist_id,
                  predicate: 'same_scene_as',
                },
              ],
              source_artist_belief_id: belief.entity_id,
            }),
          ]
        );

        if (dcRes.rows.length === 0) continue;

        const dcId = dcRes.rows[0].id;
        const relevance = Math.min(belief.value, 0.8);

        // A candidate is not a track. Claims intentionally support generic
        // entity types, so keeping this distinction prevents graph reads from
        // treating a random candidate UUID as a library track UUID.
        await this.db.upsertClaim({
          subject_type: 'discovery_candidate',
          subject_id: dcId,
          predicate: 'discovery_candidate',
          object_type: 'artist',
          object_id: row.candidate_artist_id,
          // Registered in source_trust by the System E migration. Previously
          // this unregistered value violated claims.source's foreign key.
          source: 'graph_exploration',
          confidence: relevance,
          raw: {
            discovery_source: 'graph_exploration',
            path: [
              { entity_id: belief.entity_id, relationship: 'affinity_source', belief_value: belief.value },
              { entity_id: row.candidate_artist_id, relationship: 'same_scene_as' },
            ],
          },
        });

        newCount++;
      }
    }

    return newCount;
  }

  // ---------------------------------------------------------------
  // E.3 — Evaluate discovery candidates for acquisition
  // ---------------------------------------------------------------
  async evalCandidates(
    userId: string,
    limit?: number
  ): Promise<{ candidateId: string; shouldAcquire: boolean; reason: string }[]> {
    const cap = limit ?? 20;
    const results: { candidateId: string; shouldAcquire: boolean; reason: string }[] = [];

    const candidates = await this.db.pgClient.query(
      `SELECT * FROM discovery_candidates
       WHERE status = 'candidate'
       ORDER BY first_seen_at ASC
       LIMIT $1`,
      [cap]
    );

    const backlogRes = await this.db.pgClient.query(
      `SELECT COUNT(*)::int AS cnt FROM discovery_candidates WHERE status = 'acquiring'`
    );
    let backlog = backlogRes.rows[0]?.cnt as number ?? 0;

    for (const row of candidates.rows) {
      const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>(
        `SELECT object_id, fused_value
         FROM claim_fusion
         WHERE subject_type = 'discovery_candidate' AND subject_id = $1::uuid
           AND predicate = 'discovery_candidate'
         LIMIT 1`,
        [row.id]
      );

      const relevance = claimRes.rows[0]?.fused_value ?? 0;
      const candidateArtistId = claimRes.rows[0]?.object_id;

      // A candidate must name what to fetch: either a vetted HTTPS URL, or a
      // track-level search phrase the worker resolves against its allow-listed
      // hosts. An artist-only candidate (what a bare graph walk produces) names
      // neither, and parks here rather than downloading something arbitrary
      // under that artist's name.
      const notes = typeof row.notes === 'string' ? safeJson(row.notes) : row.notes;
      const acquisition = (notes as { acquisition?: { url?: unknown; query?: unknown } } | null)?.acquisition;
      const hasUrl = typeof acquisition?.url === 'string' && acquisition.url.trim() !== '';
      const hasQuery = typeof acquisition?.query === 'string' && acquisition.query.trim() !== '';
      if (!hasUrl && !hasQuery) {
        await this.db.pgClient.query(
          `UPDATE discovery_candidates
           SET status = 'awaiting_resolution', last_eval_at = NOW(),
               last_error = 'no acquisition url or search query attached'
           WHERE id = $1`,
          [row.id]
        );
        results.push({ candidateId: row.id, shouldAcquire: false, reason: 'awaiting resolved acquisition source' });
        continue;
      }

      const noveltyBeliefs = await this.db.getListenerBeliefs({
        userId,
        profile: 'discovery',
        entityType: 'artist',
        entityId: candidateArtistId,
        dimension: 'tolerance',
        limit: 1,
      });
      const tolerance = noveltyBeliefs.length > 0 ? noveltyBeliefs[0].value : 0.5;

      let artistCount = 0;
      if (candidateArtistId) {
        const acRes = await this.db.pgClient.query(
          `SELECT COUNT(*)::int AS cnt
           FROM discovery_candidates dc
           JOIN claims c ON c.subject_id = dc.id
           WHERE dc.status = 'acquiring'
             AND c.subject_type = 'discovery_candidate'
             AND c.predicate = 'discovery_candidate'
             AND c.object_id = $1::uuid`,
          [candidateArtistId]
        );
        artistCount = acRes.rows[0]?.cnt as number ?? 0;
      }

      const shouldAcquire = relevance > 0.3 && tolerance > 0.2 && backlog < 20 && artistCount < 3;
      let reason: string;

      if (shouldAcquire) {
        await this.db.pgClient.query(
          `UPDATE discovery_candidates SET status = 'acquiring', last_eval_at = NOW() WHERE id = $1`,
          [row.id]
        );
        backlog++;
        reason = 'meets criteria';
      } else {
        if (relevance <= 0.3) reason = 'relevance too low';
        else if (tolerance <= 0.2) reason = 'novelty tolerance exceeded';
        else if (backlog >= 20) reason = 'backlog full';
        else if (artistCount >= 3) reason = 'artist diversity limit';
        else reason = 'unknown';

        await this.db.pgClient.query(
          `UPDATE discovery_candidates SET status = 'retired', last_eval_at = NOW() WHERE id = $1`,
          [row.id]
        );
      }

      results.push({
        candidateId: row.id,
        shouldAcquire,
        reason,
      });
    }

    return results;
  }

  /** Undo the state transition when Redis rejected an acquisition enqueue. */
  async markEnqueueFailed(candidateId: string, reason: string): Promise<void> {
    await this.db.pgClient.query(
      `UPDATE discovery_candidates
       SET status = 'candidate', last_eval_at = NOW(), last_error = $2
       WHERE id = $1 AND status = 'acquiring'`,
      [candidateId, reason.slice(0, 2000)]
    );
  }

  // ---------------------------------------------------------------
  // E.4 — Probation lifecycle
  // ---------------------------------------------------------------
  async evalProbation(trackId: string): Promise<'retained' | 'retired' | 'probation'> {
    const completedRes = await this.db.pgClient.query(
      `SELECT COUNT(*)::int AS cnt FROM evidence
       WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'playback_completed'`,
      [trackId]
    );
    const completedPlays = completedRes.rows[0]?.cnt as number ?? 0;

    const skipRes = await this.db.pgClient.query(
      `SELECT COUNT(*)::int AS cnt FROM evidence
       WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'skip_quick'`,
      [trackId]
    );
    const skips = skipRes.rows[0]?.cnt as number ?? 0;

    const trackRes = await this.db.pgClient.query<{ probation_entered_at: Date | null }>(
      `SELECT probation_entered_at FROM tracks WHERE id = $1`,
      [trackId]
    );
    const probTrack = trackRes.rows[0];

    if (completedPlays >= 3) {
      await this.db.pgClient.query(
        // Retained recommendations become normal library candidates. Their
        // source_type remains RECOMMENDATION for provenance, while state=LIBRARY
        // is the explicit promotion gate consumed by the existing Vibe queries.
        `UPDATE tracks
         SET probation_status = 'retained', state = 'LIBRARY'
         WHERE id = $1`,
        [trackId]
      );

      const claimRes = await this.db.pgClient.query<{ source: string }>(
        `SELECT source FROM claims
         WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'acquired_from'
         LIMIT 1`,
        [trackId]
      );
      if (claimRes.rows[0]) {
        await this.db.pgClient.query(
          `UPDATE source_trust SET trust = LEAST(1.0, trust + 0.05) WHERE key = $1`,
          [claimRes.rows[0].source]
        );
      }

      return 'retained';
    }

    const daysSinceProbation = probTrack?.probation_entered_at
      ? (Date.now() - new Date(probTrack.probation_entered_at).getTime()) / (1000 * 86400)
      : 0;

    if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) {
      await this.db.pgClient.query(
        // Retirement is reversible and never unlinks a file. The existing
        // cleanup hard-delete workflow remains separately gated.
        `UPDATE tracks SET probation_status = 'retired', state = 'HIDDEN' WHERE id = $1`,
        [trackId]
      );
      return 'retired';
    }

    return 'probation';
  }

  async sweepProbation(): Promise<{ retained: number; retired: number }> {
    const res = await this.db.pgClient.query(
      `SELECT id FROM tracks WHERE probation_status = 'probation'`
    );

    let retained = 0;
    let retired = 0;

    for (const row of res.rows) {
      const result = await this.evalProbation(row.id as string);
      if (result === 'retained') retained++;
      else if (result === 'retired') retired++;
    }

    return { retained, retired };
  }

  // ---------------------------------------------------------------
  // E.5 — Meta-learning stub
  // ---------------------------------------------------------------
  async runMetaLearning(): Promise<void> {
    const res = await this.db.pgClient.query(
      `SELECT c.source, COUNT(*)::int AS cnt
       FROM claims c
       JOIN tracks t ON t.id = c.subject_id
       WHERE c.predicate = 'acquired_from'
         AND t.probation_status = 'retained'
       GROUP BY c.source
       ORDER BY cnt DESC`
    );

    console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows));
  }
}

function safeJson(value: string): unknown {
  try {
    return JSON.parse(value);
  } catch {
    return null;
  }
}
