"""Tests for core/library/embedded_id_reconcile.py.

The reconcile job reads provider IDs already embedded in a file's tags
(by SoulSync or MusicBrainz Picard) and gap-fills them into the library
DB so enrichment workers skip the API call. These pin the guarantees that
make it safe to run across a whole library while workers run concurrently:

  1. gap-fill only — an existing id is NEVER overwritten,
  2. disagreements are reported as conflicts, not applied,
  3. the write is ATOMICALLY guarded — if a worker fills the column
     between plan and apply, the apply no-ops (no clobber).
"""

from __future__ import annotations

import sqlite3

from core.library.embedded_id_reconcile import (
    Fill,
    ReconcileApplied,
    ReconcilePlan,
    ReconcileTotals,
    apply_reconcile_plan,
    plan_reconcile,
    reconcile_library,
    reconcile_track_row,
)


# ---------------------------------------------------------------------------
# plan_reconcile — the pure planning layer
# ---------------------------------------------------------------------------

def test_empty_inputs_yield_empty_plan():
    plan = plan_reconcile(None, None)
    assert isinstance(plan, ReconcilePlan)
    assert plan.has_updates is False
    assert plan.filled == 0
    assert plan.conflicts == []


def test_fills_all_three_entities_from_one_file():
    tags = {'spotify_track_id': 'TRK', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'}
    plan = plan_reconcile(tags, {'track': {}, 'album': {}, 'artist': {}})

    assert plan.filled == 3
    by_entity = {(f.entity, f.id_column): f.value for f in plan.fills}
    assert by_entity[('track', 'spotify_track_id')] == 'TRK'
    assert by_entity[('album', 'spotify_album_id')] == 'ALB'
    assert by_entity[('artist', 'spotify_artist_id')] == 'ART'
    # status column pairing is carried on each Fill
    track_fill = plan.fills_for('track')[0]
    assert track_fill.status_column == 'spotify_match_status'


def test_never_overwrites_an_existing_id():
    plan = plan_reconcile({'spotify_artist_id': 'NEW'},
                          {'artist': {'spotify_artist_id': 'EXISTING'}})
    assert plan.filled == 0
    assert plan.fills_for('artist') == []
    assert len(plan.conflicts) == 1
    c = plan.conflicts[0]
    assert c['existing'] == 'EXISTING' and c['embedded'] == 'NEW' and c['entity'] == 'artist'


def test_matching_existing_id_is_noop_not_conflict():
    plan = plan_reconcile({'spotify_artist_id': 'SAME'},
                          {'artist': {'spotify_artist_id': 'SAME'}})
    assert plan.filled == 0
    assert plan.conflicts == []
    assert plan.already_present == 1


def test_blank_and_whitespace_values_ignored():
    tags = {'spotify_artist_id': '   ', 'spotify_album_id': '', 'itunes_track_id': None}
    plan = plan_reconcile(tags, {'track': {}, 'album': {}, 'artist': {}})
    assert plan.has_updates is False


def test_whitespace_padded_embedded_id_is_trimmed_and_filled():
    plan = plan_reconcile({'spotify_track_id': '  TRK  '}, {'track': {}})
    assert plan.fills_for('track')[0].value == 'TRK'


def test_single_column_provider_maps_per_entity():
    # Deezer/Tidal/AudioDB reuse one id column across entity types; fills
    # must be keyed by entity so they don't collide.
    tags = {'deezer_track_id': 'DT', 'deezer_album_id': 'DA', 'deezer_artist_id': 'DR'}
    plan = plan_reconcile(tags, {'track': {}, 'album': {}, 'artist': {}})
    vals = {f.entity: f.value for f in plan.fills}
    assert vals == {'track': 'DT', 'album': 'DA', 'artist': 'DR'}
    assert plan.filled == 3


def test_mb_album_and_artist_filled_track_recording_skipped():
    tags = {'musicbrainz_albumid': 'MBA', 'musicbrainz_artistid': 'MBR', 'musicbrainz_trackid': 'MBT'}
    plan = plan_reconcile(tags, {'track': {}, 'album': {}, 'artist': {}})
    cols = {(f.entity, f.id_column): f.value for f in plan.fills}
    assert cols[('album', 'musicbrainz_release_id')] == 'MBA'
    assert cols[('artist', 'musicbrainz_id')] == 'MBR'
    assert plan.fills_for('track') == []  # recording id not reconciled


def test_lastfm_url_maps_to_track_only():
    # The file carries a single LASTFM_URL = the TRACK's last.fm url. It must
    # fill tracks.lastfm_url and NOT be smeared onto album/artist (whose
    # last.fm urls are different urls entirely).
    plan = plan_reconcile({'lastfm_url': 'https://last.fm/music/A/_/Song'},
                          {'track': {}, 'album': {}, 'artist': {}})
    assert plan.filled == 1
    f = plan.fills_for('track')[0]
    assert f.id_column == 'lastfm_url' and f.status_column == 'lastfm_match_status'
    assert plan.fills_for('album') == [] and plan.fills_for('artist') == []


def test_partial_fill_when_one_entity_already_matched():
    tags = {'spotify_artist_id': 'ART', 'spotify_album_id': 'ALB'}
    current = {'artist': {'spotify_artist_id': 'ART'}, 'album': {}}
    plan = plan_reconcile(tags, current)
    assert plan.filled == 1
    assert plan.fills_for('album')[0].value == 'ALB'
    assert plan.fills_for('artist') == []
    assert plan.already_present == 1


# ---------------------------------------------------------------------------
# apply_reconcile_plan — the DB layer (in-memory sqlite)
# ---------------------------------------------------------------------------

def _make_db():
    conn = sqlite3.connect(':memory:')
    cur = conn.cursor()
    for table, idcol in (('tracks', 'spotify_track_id'), ('albums', 'spotify_album_id'),
                         ('artists', 'spotify_artist_id')):
        cur.execute(f"""CREATE TABLE {table} (id TEXT PRIMARY KEY, {idcol} TEXT,
            spotify_match_status TEXT, spotify_last_attempted TIMESTAMP)""")
    cur.execute("INSERT INTO tracks (id) VALUES ('t1')")
    cur.execute("INSERT INTO albums (id) VALUES ('al1')")
    cur.execute("INSERT INTO artists (id) VALUES ('ar1')")
    conn.commit()
    return conn, cur


def test_apply_writes_ids_status_and_timestamp():
    conn, cur = _make_db()
    plan = plan_reconcile(
        {'spotify_track_id': 'TRK', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'},
        {'track': {}, 'album': {}, 'artist': {}},
    )
    applied = apply_reconcile_plan(cur, {'track': 't1', 'album': 'al1', 'artist': 'ar1'}, plan)
    conn.commit()
    assert isinstance(applied, ReconcileApplied)
    assert applied.rows_updated == 3 and applied.ids_filled == 3

    cur.execute("SELECT spotify_track_id, spotify_match_status, spotify_last_attempted FROM tracks WHERE id='t1'")
    tid, status, attempted = cur.fetchone()
    assert tid == 'TRK' and status == 'matched' and attempted is not None


def test_apply_guard_blocks_overwrite_under_concurrency():
    # THE headline hardening: a worker fills the column AFTER we planned
    # (plan saw empty) but BEFORE we apply. The guarded UPDATE must no-op
    # and leave the worker's value intact.
    conn, cur = _make_db()
    plan = plan_reconcile({'spotify_artist_id': 'FROM_FILE'}, {'artist': {}})  # planned: empty
    # Simulate a concurrent enrichment worker matching it in the meantime.
    cur.execute("UPDATE artists SET spotify_artist_id='FROM_WORKER', spotify_match_status='matched' WHERE id='ar1'")
    conn.commit()

    applied = apply_reconcile_plan(cur, {'artist': 'ar1'}, plan)
    conn.commit()
    assert applied.ids_filled == 0 and applied.rows_updated == 0  # guard blocked it

    cur.execute("SELECT spotify_artist_id FROM artists WHERE id='ar1'")
    assert cur.fetchone()[0] == 'FROM_WORKER'  # worker's value preserved


def test_apply_guard_treats_empty_string_as_fillable():
    conn, cur = _make_db()
    cur.execute("UPDATE artists SET spotify_artist_id='' WHERE id='ar1'")  # empty string, not NULL
    conn.commit()
    plan = plan_reconcile({'spotify_artist_id': 'ART'}, {'artist': {}})
    applied = apply_reconcile_plan(cur, {'artist': 'ar1'}, plan)
    conn.commit()
    assert applied.ids_filled == 1
    cur.execute("SELECT spotify_artist_id FROM artists WHERE id='ar1'")
    assert cur.fetchone()[0] == 'ART'


def test_apply_skips_unknown_columns_without_erroring():
    # Schema missing a provider's columns must not raise — the plan targets
    # tidal_id which this minimal schema lacks; it's silently skipped.
    conn, cur = _make_db()
    plan = plan_reconcile({'tidal_artist_id': 'TID', 'spotify_artist_id': 'ART'},
                          {'track': {}, 'album': {}, 'artist': {}})
    applied = apply_reconcile_plan(cur, {'artist': 'ar1'}, plan)
    conn.commit()
    cur.execute("SELECT spotify_artist_id FROM artists WHERE id='ar1'")
    assert cur.fetchone()[0] == 'ART'
    assert applied.ids_filled == 1  # only the existing spotify column landed


def test_apply_skips_entity_with_no_id():
    conn, cur = _make_db()
    plan = plan_reconcile({'spotify_album_id': 'ALB'}, {'album': {}})
    applied = apply_reconcile_plan(cur, {'track': 't1'}, plan)  # no album id supplied
    assert applied.rows_updated == 0 and applied.ids_filled == 0


def test_apply_empty_plan_is_noop():
    conn, cur = _make_db()
    applied = apply_reconcile_plan(cur, {'track': 't1'}, ReconcilePlan())
    assert applied.rows_updated == 0 and applied.ids_filled == 0


# ---------------------------------------------------------------------------
# reconcile_track_row — the per-track orchestration (id extraction, plan→apply,
# sibling-map freshening)
# ---------------------------------------------------------------------------

def test_reconcile_track_row_unreadable_file_is_noop():
    conn, cur = _make_db()
    result = reconcile_track_row(cur, {'id': 't1'}, {}, {}, None)
    assert result.readable is False
    assert result.applied.ids_filled == 0


def test_reconcile_track_row_fills_track_and_parents():
    conn, cur = _make_db()
    track_row = {'id': 't1', 'album_id': 'al1', 'artist_id': 'ar1'}
    album_map = {'al1': {}}
    artist_map = {'ar1': {}}
    tags = {'spotify_track_id': 'TRK', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'}
    result = reconcile_track_row(cur, track_row, album_map, artist_map, tags)
    conn.commit()
    assert result.readable is True
    assert result.applied.ids_filled == 3 and result.applied.rows_updated == 3
    # parent maps were freshened in place
    assert album_map['al1']['spotify_album_id'] == 'ALB'
    assert artist_map['ar1']['spotify_artist_id'] == 'ART'


def test_reconcile_sibling_tracks_dont_refill_shared_parent():
    # Two tracks on the same album/artist. The first fills the album+artist
    # ids; the second must see them already present (via the freshened map)
    # and NOT re-apply — proving the map keeps siblings from redundant work.
    conn, cur = _make_db()
    cur.execute("INSERT INTO tracks (id) VALUES ('t2')")
    conn.commit()
    album_map = {'al1': {}}
    artist_map = {'ar1': {}}
    tags = {'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART', 'spotify_track_id': 'T1'}

    r1 = reconcile_track_row(cur, {'id': 't1', 'album_id': 'al1', 'artist_id': 'ar1'},
                             album_map, artist_map, tags)
    # Second track: same album/artist ids embedded, its own track id.
    tags2 = {'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART', 'spotify_track_id': 'T2'}
    r2 = reconcile_track_row(cur, {'id': 't2', 'album_id': 'al1', 'artist_id': 'ar1'},
                             album_map, artist_map, tags2)
    conn.commit()

    assert r1.applied.ids_filled == 3            # track + album + artist
    assert r2.applied.ids_filled == 1            # only t2's own track id; parents already filled
    assert r2.conflicts == 0


def test_reconcile_track_row_handles_null_parent_ids():
    conn, cur = _make_db()
    # Track with no album/artist linkage — only its own id should fill.
    result = reconcile_track_row(cur, {'id': 't1', 'album_id': None, 'artist_id': None},
                                 {}, {}, {'spotify_track_id': 'TRK', 'spotify_album_id': 'ALB'})
    conn.commit()
    assert result.applied.ids_filled == 1  # album fill has no album id to land on
    cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t1'")
    assert cur.fetchone()[0] == 'TRK'


# ---------------------------------------------------------------------------
# reconcile_library — the shared orchestration (paging, lazy maps, scope)
# ---------------------------------------------------------------------------

def _make_library_db():
    conn = sqlite3.connect(':memory:')
    conn.row_factory = sqlite3.Row
    cur = conn.cursor()
    cur.execute("""CREATE TABLE tracks (id TEXT PRIMARY KEY, album_id TEXT, artist_id TEXT,
        file_path TEXT, title TEXT, spotify_track_id TEXT, spotify_match_status TEXT,
        spotify_last_attempted TIMESTAMP)""")
    cur.execute("""CREATE TABLE albums (id TEXT PRIMARY KEY, spotify_album_id TEXT,
        spotify_match_status TEXT, spotify_last_attempted TIMESTAMP)""")
    cur.execute("""CREATE TABLE artists (id TEXT PRIMARY KEY, spotify_artist_id TEXT,
        spotify_match_status TEXT, spotify_last_attempted TIMESTAMP)""")
    # Two tracks on the same album/artist, one orphan track with no file.
    cur.execute("INSERT INTO artists (id) VALUES ('ar1')")
    cur.execute("INSERT INTO albums (id) VALUES ('al1')")
    cur.execute("INSERT INTO tracks (id, album_id, artist_id, file_path, title) VALUES ('t1','al1','ar1','/a.flac','A')")
    cur.execute("INSERT INTO tracks (id, album_id, artist_id, file_path, title) VALUES ('t2','al1','ar1','/b.flac','B')")
    cur.execute("INSERT INTO tracks (id, album_id, artist_id, file_path, title) VALUES ('t3','al1','ar1','','NoFile')")
    conn.commit()
    return conn, cur


def _reader(mapping):
    """read_tags stub: file_path -> tags dict (or None)."""
    return lambda fp: mapping.get(fp)


def test_reconcile_library_whole_library_fills_all():
    conn, cur = _make_library_db()
    tags = {
        '/a.flac': {'spotify_track_id': 'TA', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'},
        '/b.flac': {'spotify_track_id': 'TB', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'},
    }
    totals = reconcile_library(conn, _reader(tags))
    assert isinstance(totals, ReconcileTotals)
    # t3 has empty file_path -> excluded from the whole-library SELECT entirely.
    assert totals.total == 2 and totals.processed == 2
    # t1: track+album+artist (3); t2: only its own track id (parents already filled) (1)
    assert totals.ids_filled == 4
    cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t2'")
    assert cur.fetchone()[0] == 'TB'
    cur.execute("SELECT spotify_album_id FROM albums WHERE id='al1'")
    assert cur.fetchone()[0] == 'ALB'


def test_reconcile_library_scoped_to_given_ids():
    conn, cur = _make_library_db()
    tags = {'/b.flac': {'spotify_track_id': 'TB'}, '/a.flac': {'spotify_track_id': 'TA'}}
    totals = reconcile_library(conn, _reader(tags), track_ids=['t2'])
    assert totals.total == 1 and totals.ids_filled == 1
    cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t2'")
    assert cur.fetchone()[0] == 'TB'
    cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t1'")
    assert cur.fetchone()[0] is None  # not in scope


def test_reconcile_library_unreadable_counted():
    conn, cur = _make_library_db()
    totals = reconcile_library(conn, _reader({}), track_ids=['t1', 't2'])  # reader returns None
    assert totals.unreadable == 2 and totals.ids_filled == 0


def test_reconcile_library_is_idempotent():
    conn, cur = _make_library_db()
    tags = {'/a.flac': {'spotify_track_id': 'TA', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'}}
    first = reconcile_library(conn, _reader(tags), track_ids=['t1'])
    second = reconcile_library(conn, _reader(tags), track_ids=['t1'])
    assert first.ids_filled == 3
    assert second.ids_filled == 0  # nothing left to fill


def test_reconcile_library_progress_and_stop():
    conn, cur = _make_library_db()
    seen = []
    reconcile_library(conn, _reader({'/a.flac': {'spotify_track_id': 'TA'}}),
                      track_ids=['t1', 't2'],
                      on_progress=lambda totals, title: seen.append(title))
    assert seen == ['A', 'B']

    # should_stop halts before processing anything
    totals = reconcile_library(conn, _reader({}), track_ids=['t1', 't2'],
                               should_stop=lambda: True)
    assert totals.processed == 0
