# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

import unittest
from dataclasses import dataclass
from typing import List

from pypaimon.index.index_file_meta import IndexFileMeta
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
from pypaimon.manifest.index_manifest_file import IndexManifestFile
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.manifest_entry import ManifestEntry
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.write.commit.conflict_detection import (
    ConflictDetection,
    RowIdColumnConflictChecker,
)


def _make_file(file_name, row_count=100, first_row_id=None,
               schema_id=0, write_cols=None):
    return DataFileMeta(
        file_name=file_name,
        file_size=1024,
        row_count=row_count,
        min_key=None,
        max_key=None,
        key_stats=None,
        value_stats=None,
        min_sequence_number=0,
        max_sequence_number=0,
        schema_id=schema_id,
        level=0,
        extra_files=[],
        first_row_id=first_row_id,
        write_cols=write_cols,
    )


_EMPTY_PARTITION = GenericRow([], [])


def _make_entry(file_name, kind=0, bucket=0, first_row_id=None,
                row_count=100, write_cols=None, schema_id=0):
    return ManifestEntry(
        kind=kind,
        partition=_EMPTY_PARTITION,
        bucket=bucket,
        total_buckets=1,
        file=_make_file(file_name, row_count=row_count,
                        first_row_id=first_row_id, schema_id=schema_id,
                        write_cols=write_cols),
    )


@dataclass
class _FakeSchema:
    id: int
    fields: List[DataField]


class _FakeSchemaManager:

    def __init__(self, schemas=None):
        self._schemas = {}
        if schemas:
            for s in schemas:
                self._schemas[s.id] = s

    def get_schema(self, schema_id):
        return self._schemas.get(schema_id)


_DEFAULT_SCHEMA = _FakeSchema(
    id=0,
    fields=[
        DataField(1, "col_a", AtomicType("INT")),
        DataField(2, "col_b", AtomicType("STRING")),
        DataField(3, "col_c", AtomicType("BIGINT")),
    ],
)


class TestCheckRowIdExistence(unittest.TestCase):

    def _make_detection(self):
        return ConflictDetection(
            data_evolution_enabled=True,
            snapshot_manager=None,
            manifest_list_manager=None,
            table=None,
            commit_scanner=None,
        )

    def test_no_conflict_when_base_file_exists(self):
        detection = self._make_detection()
        base = [_make_entry("f1", kind=0, first_row_id=0, row_count=100)]
        delta = [_make_entry("p1", kind=0, first_row_id=0, row_count=100)]
        self.assertIsNone(
            detection.check_row_id_existence(base, delta, next_row_id=200))

    def test_conflict_when_base_file_removed(self):
        detection = self._make_detection()
        base = []
        delta = [_make_entry("p1", kind=0, first_row_id=0, row_count=100)]
        result = detection.check_row_id_existence(base, delta, next_row_id=200)
        self.assertIsNotNone(result)
        self.assertIn("Row ID existence conflict", str(result))

    def test_conflict_when_base_file_rewritten(self):
        detection = self._make_detection()
        base = [_make_entry("f2", kind=0, first_row_id=0, row_count=200)]
        delta = [_make_entry("p1", kind=0, first_row_id=0, row_count=100)]
        result = detection.check_row_id_existence(base, delta, next_row_id=200)
        self.assertIsNotNone(result)
        self.assertIn("Row ID existence conflict", str(result))

    def test_no_conflict_when_blob_file_range_is_covered(self):
        detection = self._make_detection()
        base = [_make_entry("f1", kind=0, first_row_id=0, row_count=100)]
        delta = [_make_entry("p1.blob", kind=0, first_row_id=20, row_count=10)]
        self.assertIsNone(
            detection.check_row_id_existence(base, delta, next_row_id=200))

    def test_no_conflict_when_vector_file_range_is_covered(self):
        detection = self._make_detection()
        base = [_make_entry("f1", kind=0, first_row_id=0, row_count=100)]
        delta = [_make_entry("p1.vector.0", kind=0, first_row_id=20, row_count=10)]
        self.assertIsNone(
            detection.check_row_id_existence(base, delta, next_row_id=200))

    def test_conflict_when_blob_file_range_is_not_covered(self):
        detection = self._make_detection()
        base = [_make_entry("f1", kind=0, first_row_id=0, row_count=100)]
        delta = [_make_entry("p1.blob", kind=0, first_row_id=95, row_count=10)]
        result = detection.check_row_id_existence(base, delta, next_row_id=200)
        self.assertIsNotNone(result)
        self.assertIn("Row ID existence conflict", str(result))

    def test_no_conflict_when_blob_file_range_is_covered_by_multiple_files(self):
        detection = self._make_detection()
        base = [
            _make_entry("f1", kind=0, first_row_id=0, row_count=50),
            _make_entry("f2", kind=0, first_row_id=50, row_count=50),
        ]
        delta = [_make_entry("p1.blob", kind=0, first_row_id=25, row_count=50)]
        self.assertIsNone(
            detection.check_row_id_existence(base, delta, next_row_id=200))

    def test_conflict_when_blob_file_range_is_only_covered_by_base_blob_file(self):
        detection = self._make_detection()
        base = [
            _make_entry("f1", kind=0, first_row_id=0, row_count=50),
            _make_entry("p0.blob", kind=0, first_row_id=50, row_count=50),
        ]
        delta = [_make_entry("p1.blob", kind=0, first_row_id=60, row_count=10)]
        result = detection.check_row_id_existence(base, delta, next_row_id=200)
        self.assertIsNotNone(result)
        self.assertIn("Row ID existence conflict", str(result))

    def test_skip_newly_appended_files(self):
        detection = self._make_detection()
        base = []
        delta = [_make_entry("p1", kind=0, first_row_id=200, row_count=100)]
        self.assertIsNone(
            detection.check_row_id_existence(base, delta, next_row_id=200))

    def test_skip_when_no_pre_assigned_row_id(self):
        detection = self._make_detection()
        base = []
        delta = [_make_entry("f1", kind=0)]
        self.assertIsNone(
            detection.check_row_id_existence(base, delta, next_row_id=200))

    def test_skip_delete_entries(self):
        detection = self._make_detection()
        base = []
        delta = [_make_entry("f1", kind=1, first_row_id=0, row_count=100)]
        self.assertIsNone(
            detection.check_row_id_existence(base, delta, next_row_id=200))

    def test_skip_when_data_evolution_disabled(self):
        detection = ConflictDetection(
            data_evolution_enabled=False,
            snapshot_manager=None,
            manifest_list_manager=None,
            table=None,
            commit_scanner=None,
        )
        base = []
        delta = [_make_entry("p1", kind=0, first_row_id=0, row_count=100)]
        self.assertIsNone(
            detection.check_row_id_existence(base, delta, next_row_id=200))

    def test_skip_when_next_row_id_is_none(self):
        detection = self._make_detection()
        base = []
        delta = [_make_entry("p1", kind=0, first_row_id=0, row_count=100)]
        self.assertIsNone(
            detection.check_row_id_existence(base, delta, next_row_id=None))


class TestCheckRowIdRangeConflicts(unittest.TestCase):

    def _make_detection(self):
        return ConflictDetection(
            data_evolution_enabled=True,
            snapshot_manager=None,
            manifest_list_manager=None,
            table=None,
            commit_scanner=None,
        )

    def test_reports_dedicated_file_spanning_data_files(self):
        detection = self._make_detection()
        entries = [
            _make_entry("f1", kind=0, first_row_id=0, row_count=2),
            _make_entry("f2", kind=0, first_row_id=2, row_count=2),
            _make_entry("p1.blob", kind=0, first_row_id=0, row_count=4),
        ]

        result = detection.check_row_id_range_conflicts("COMPACT", entries)

        self.assertIsNotNone(result)
        self.assertIn("dedicated file", str(result))
        self.assertIn("p1.blob", str(result))
        self.assertIn("spans multiple data file ranges", str(result))
        self.assertIn("f1", str(result))
        self.assertIn("f2", str(result))

    def test_allows_adjacent_data_files(self):
        detection = self._make_detection()
        entries = [
            _make_entry("f1", kind=0, first_row_id=0, row_count=2),
            _make_entry("f2", kind=0, first_row_id=2, row_count=2),
        ]

        result = detection.check_row_id_range_conflicts("COMPACT", entries)

        self.assertIsNone(result)

    def test_allows_dedicated_file_covered_by_one_data_file(self):
        detection = self._make_detection()
        entries = [
            _make_entry("f1", kind=0, first_row_id=0, row_count=4),
            _make_entry("p1.blob", kind=0, first_row_id=1, row_count=2),
        ]

        result = detection.check_row_id_range_conflicts("COMPACT", entries)

        self.assertIsNone(result)


class TestOverwriteConflictDetection(unittest.TestCase):

    def _make_detection(self):
        return ConflictDetection(
            data_evolution_enabled=True,
            snapshot_manager=None,
            manifest_list_manager=None,
            table=None,
            commit_scanner=None,
        )

    def test_deleted_files_trigger_overwrite_commit(self):
        detection = self._make_detection()
        entries = [
            _make_entry("f1", kind=0),
            _make_entry("f2", kind=1),
        ]
        self.assertTrue(detection.should_be_overwrite_commit(entries, []))

    def test_deletion_vector_index_files_trigger_overwrite_commit(self):
        detection = self._make_detection()
        index_entry = IndexManifestEntry(
            kind=0,
            partition=_EMPTY_PARTITION,
            bucket=0,
            index_file=IndexFileMeta(
                index_type=IndexManifestFile.DELETION_VECTORS_INDEX,
                file_name="dv",
                file_size=1,
                row_count=1,
            ),
        )
        self.assertTrue(detection.should_be_overwrite_commit([], [index_entry]))

    def test_delete_entry_missing_from_base_conflicts(self):
        detection = self._make_detection()
        result = detection.check_conflicts(
            latest_snapshot=None,
            base_entries=[],
            delta_entries=[_make_entry("missing", kind=1)],
            commit_kind="OVERWRITE",
        )
        self.assertIsNotNone(result)
        self.assertIn("File deletion conflicts", str(result))


class _FakeSnapshot:

    def __init__(self, snapshot_id, commit_kind, next_row_id=None):
        self.id = snapshot_id
        self.commit_kind = commit_kind
        self.next_row_id = next_row_id


class _FakeSnapshotManager:

    def __init__(self, snapshots):
        self._by_id = {s.id: s for s in snapshots}

    def get_snapshot_by_id(self, snapshot_id):
        return self._by_id.get(snapshot_id)


class _FakeCommitScanner:

    def __init__(self, entries_by_snapshot_id, raw_entries_by_snapshot_id=None):
        self._by_id = entries_by_snapshot_id
        self._raw_by_id = raw_entries_by_snapshot_id or {}

    def read_incremental_entries_from_changed_partitions(self, snapshot, _):
        return self._by_id.get(snapshot.id, [])

    def read_incremental_raw_entries_from_changed_partitions(self, snapshot, _):
        return self._raw_by_id.get(snapshot.id, self._by_id.get(snapshot.id, []))


class _FakeTable:

    def __init__(self, schema_manager):
        self.schema_manager = schema_manager


class TestCheckRowIdFromSnapshot(unittest.TestCase):

    def _make_detection(self, snapshots, raw_entries_by_snapshot_id):
        detection = ConflictDetection(
            data_evolution_enabled=True,
            snapshot_manager=_FakeSnapshotManager(snapshots),
            manifest_list_manager=None,
            table=_FakeTable(_FakeSchemaManager([_DEFAULT_SCHEMA])),
            commit_scanner=_FakeCommitScanner({}, raw_entries_by_snapshot_id),
        )
        detection._row_id_check_from_snapshot = 1
        return detection

    def _blob_delta(self):
        return [_make_entry("d.blob", first_row_id=0, row_count=51,
                            write_cols=["col_a"])]

    def test_compact_blob_delete_raises_at_first_match(self):
        check_snap = _FakeSnapshot(1, "APPEND", next_row_id=200)
        compact1 = _FakeSnapshot(2, "COMPACT", next_row_id=200)
        compact2 = _FakeSnapshot(3, "COMPACT", next_row_id=200)
        entries = {
            2: [_make_entry("first.blob", kind=1, first_row_id=0, row_count=200)],
            3: [_make_entry("second.blob", kind=1, first_row_id=0, row_count=200)],
        }
        detection = self._make_detection(
            [check_snap, compact1, compact2], entries)
        result = detection.check_row_id_from_snapshot(compact2, self._blob_delta())
        self.assertIsNotNone(result)
        self.assertIn("snapshot 2", str(result))
        self.assertIn("COMPACT", str(result))

    def test_compact_other_file_type_does_not_raise(self):
        check_snap = _FakeSnapshot(1, "APPEND", next_row_id=200)
        compact_snap = _FakeSnapshot(2, "COMPACT", next_row_id=200)
        compact_entries = [
            _make_entry("old.parquet", kind=1, first_row_id=0, row_count=100),
            _make_entry("merged.parquet", kind=0, first_row_id=0, row_count=200),
        ]
        detection = self._make_detection(
            [check_snap, compact_snap], {2: compact_entries})
        self.assertIsNone(
            detection.check_row_id_from_snapshot(compact_snap, self._blob_delta()))

    def test_compact_no_conflict_when_no_matching_delete(self):
        check_snap = _FakeSnapshot(1, "APPEND", next_row_id=400)
        compact_snap = _FakeSnapshot(2, "COMPACT", next_row_id=400)
        col_a_delta = self._blob_delta()
        col_b_delta = [_make_entry("d.parquet", first_row_id=0, row_count=51,
                                   write_cols=["col_b"])]
        cases = [
            ("disjoint_range", col_a_delta, [
                _make_entry("old.blob", kind=1, first_row_id=200, row_count=200),
            ]),
            ("add_only", col_a_delta, [
                _make_entry("merged.blob", kind=0, first_row_id=0, row_count=200),
            ]),
            ("other_column_shard", col_b_delta, [
                _make_entry("old.parquet", kind=1, first_row_id=0, row_count=100,
                            write_cols=["col_a"]),
            ]),
        ]
        for name, delta, compact_entries in cases:
            with self.subTest(case=name):
                detection = self._make_detection(
                    [check_snap, compact_snap], {2: compact_entries})
                self.assertIsNone(
                    detection.check_row_id_from_snapshot(compact_snap, delta))


class TestRowIdColumnConflictChecker(unittest.TestCase):

    def _make_checker(self, delta_files, schema=None):
        schema_mgr = _FakeSchemaManager([schema or _DEFAULT_SCHEMA])
        return RowIdColumnConflictChecker.from_data_files(schema_mgr, delta_files)

    def test_no_conflict_disjoint_rows(self):
        delta_files = [
            _make_file("d1", row_count=100, first_row_id=0, write_cols=["col_a"]),
        ]
        checker = self._make_checker(delta_files)
        committed = _make_file("c1", row_count=100, first_row_id=200,
                               write_cols=["col_a"])
        self.assertFalse(checker.conflicts_with(committed))

    def test_no_conflict_same_rows_different_columns(self):
        delta_files = [
            _make_file("d1", row_count=100, first_row_id=0, write_cols=["col_a"]),
        ]
        checker = self._make_checker(delta_files)
        committed = _make_file("c1", row_count=100, first_row_id=0,
                               write_cols=["col_b"])
        self.assertFalse(checker.conflicts_with(committed))

    def test_conflict_same_rows_same_columns(self):
        delta_files = [
            _make_file("d1", row_count=100, first_row_id=0, write_cols=["col_a"]),
        ]
        checker = self._make_checker(delta_files)
        committed = _make_file("c1", row_count=100, first_row_id=0,
                               write_cols=["col_a"])
        self.assertTrue(checker.conflicts_with(committed))

    def test_conflict_overlapping_rows_overlapping_columns(self):
        delta_files = [
            _make_file("d1", row_count=100, first_row_id=0,
                       write_cols=["col_a", "col_b"]),
        ]
        checker = self._make_checker(delta_files)
        committed = _make_file("c1", row_count=100, first_row_id=50,
                               write_cols=["col_b", "col_c"])
        self.assertTrue(checker.conflicts_with(committed))

    def test_conflict_null_write_cols_committed(self):
        """null write_cols means full-schema write — always conflicts on column dimension."""
        delta_files = [
            _make_file("d1", row_count=100, first_row_id=0, write_cols=["col_a"]),
        ]
        checker = self._make_checker(delta_files)
        committed = _make_file("c1", row_count=100, first_row_id=0,
                               write_cols=None)
        self.assertTrue(checker.conflicts_with(committed))

    def test_conflict_null_write_cols_delta(self):
        """null write_cols in delta means all columns are in the write range."""
        delta_files = [
            _make_file("d1", row_count=100, first_row_id=0, write_cols=None),
        ]
        checker = self._make_checker(delta_files)
        committed = _make_file("c1", row_count=100, first_row_id=0,
                               write_cols=["col_b"])
        self.assertTrue(checker.conflicts_with(committed))

    def test_no_conflict_committed_file_no_row_id(self):
        delta_files = [
            _make_file("d1", row_count=100, first_row_id=0, write_cols=["col_a"]),
        ]
        checker = self._make_checker(delta_files)
        committed = _make_file("c1", row_count=100, first_row_id=None,
                               write_cols=["col_a"])
        self.assertFalse(checker.conflicts_with(committed))

    def test_none_when_no_delta_files_with_row_id(self):
        delta_files = [
            _make_file("d1", row_count=100, first_row_id=None),
        ]
        schema_mgr = _FakeSchemaManager([_DEFAULT_SCHEMA])
        checker = RowIdColumnConflictChecker.from_data_files(schema_mgr, delta_files)
        self.assertIsNone(checker)

    def test_system_fields_skipped(self):
        """System fields like _ROW_ID should not count as column conflicts."""
        delta_files = [
            _make_file("d1", row_count=100, first_row_id=0,
                       write_cols=["_ROW_ID", "col_a"]),
        ]
        checker = self._make_checker(delta_files)
        committed = _make_file("c1", row_count=100, first_row_id=0,
                               write_cols=["_ROW_ID", "col_b"])
        self.assertFalse(checker.conflicts_with(committed))

    def test_cross_schema_field_id_resolution(self):
        """Fields with same ID but different names across schema versions should still match."""
        schema_v0 = _FakeSchema(
            id=0,
            fields=[
                DataField(1, "col_a", AtomicType("INT")),
                DataField(2, "col_b", AtomicType("STRING")),
            ],
        )
        schema_v1 = _FakeSchema(
            id=1,
            fields=[
                DataField(1, "col_a_renamed", AtomicType("INT")),
                DataField(2, "col_b", AtomicType("STRING")),
                DataField(3, "col_c", AtomicType("BIGINT")),
            ],
        )
        schema_mgr = _FakeSchemaManager([schema_v0, schema_v1])
        delta_files = [
            _make_file("d1", row_count=100, first_row_id=0,
                       schema_id=0, write_cols=["col_a"]),
        ]
        checker = RowIdColumnConflictChecker.from_data_files(schema_mgr, delta_files)
        committed_same_field = _make_file(
            "c1", row_count=100, first_row_id=0,
            schema_id=1, write_cols=["col_a_renamed"])
        self.assertTrue(checker.conflicts_with(committed_same_field))
        committed_diff_field = _make_file(
            "c2", row_count=100, first_row_id=0,
            schema_id=1, write_cols=["col_c"])
        self.assertFalse(checker.conflicts_with(committed_diff_field))


if __name__ == '__main__':
    unittest.main()
