import 'article.dart';
import 'contact_source.dart';
import 'customer.dart';
import 'delivery.dart';
import 'delivery_credit.dart';
import 'delivery_item.dart';
import 'delivery_note.dart';
import 'delivery_service_value.dart';
import 'service.dart';
import 'tour.dart';
import 'warehouse.dart';

/// Voll geladenes Tour-Aggregat. Enthält die Tour selbst, alle Lieferungen
/// inkl. Items sowie *alle* Stammdaten, die von diesem Schnitt referenziert
/// werden. Die Stammdaten kommen als Lookup-Maps statt als List, damit das
/// UI ohne O(n)-Suchen auskommt.
///
/// Die Notizen sind im Backend in einer flachen Liste — wir indizieren sie
/// hier einmal per `deliveryId`, weil das UI sie immer „pro Lieferung"
/// braucht.
class TourDetails {
  TourDetails({
    required this.tour,
    required this.deliveries,
    required this.customers,
    required this.contacts,
    required this.articles,
    required this.warehouses,
    required this.notesByDeliveryId,
    required this.creditsByDeliveryId,
    required this.services,
    required this.serviceValuesByDeliveryId,
    required this.contactSourcesByDeliveryId,
    required this.contactChannelsBySourceId,
  });

  final Tour tour;

  /// Alle Lieferungen dieser Tour. Reihenfolge: unsortiert; UI ruft
  /// `deliveriesSorted` auf, wenn Sortier-Reihenfolge benötigt wird.
  final List<Delivery> deliveries;

  // ─── Stammdaten-Lookups (Id → Entity) ─────────────────────────────────

  final Map<String, Customer> customers;
  final Map<String, CustomerContact> contacts;
  final Map<String, Article> articles;
  final Map<String, Warehouse> warehouses;

  /// Pro Lieferung: alle Notizen, aufsteigend nach `createdAt`. Wenn eine
  /// Lieferung keine Notizen hat, liefert der Lookup `null` zurück — das
  /// UI muss das berücksichtigen.
  final Map<String, List<DeliveryNote>> notesByDeliveryId;

  /// Pro Lieferung die aktuelle Betrags-Gutschrift (höchstens eine). Fehlt
  /// der Eintrag, gibt es aktuell keine Gutschrift.
  final Map<String, DeliveryCredit> creditsByDeliveryId;

  /// Aktive Service-Definitionen (Stammdaten), nach `sortOrder`. Daraus
  /// rendert Phase 4 die Auswahl.
  final List<Service> services;

  /// Pro Lieferung die gesetzten Service-Werte, indiziert per `serviceId`.
  final Map<String, Map<String, DeliveryServiceValue>> serviceValuesByDeliveryId;

  /// Pro Lieferung die Adress-Quellen aus dem ERP (Belegadresse / Liefer-
  /// adresse / Rechnungsadresse / Ansprechpartner / Kundenstamm). Wird vom
  /// Sync gefüllt; leere Quellen kommen nicht durch — wer hier 0 Einträge
  /// sieht, hat im ERP keinen einzigen Kontakt am Beleg hängen.
  final Map<String, List<ContactSource>> contactSourcesByDeliveryId;

  /// Pro Quelle alle ihre Kommunikationskanäle. Reihenfolge folgt der
  /// ERP-Position (Telefon 1 → Position 1, Telefon 2 → Position 2, …),
  /// das UI kann die Liste direkt rendern.
  final Map<String, List<ContactChannel>> contactChannelsBySourceId;

  // ─── Convenience für UI ───────────────────────────────────────────────

  /// Lieferungen sortiert nach `sortOrder` aufsteigend. Falls zwei
  /// Lieferungen identische Werte tragen (sollte nicht vorkommen, dient
  /// nur als Defensive), fällt der Vergleich auf die Belegnummer zurück.
  List<Delivery> get deliveriesSorted {
    final copy = List<Delivery>.of(deliveries);
    copy.sort((a, b) {
      final byOrder = a.sortOrder.compareTo(b.sortOrder);
      if (byOrder != 0) return byOrder;
      return a.erpBelegnummer.compareTo(b.erpBelegnummer);
    });
    return copy;
  }

  Customer? customerOf(Delivery delivery) => customers[delivery.customerId];

  Iterable<CustomerContact> contactsOf(Delivery delivery) sync* {
    for (final id in delivery.contactPersonIds) {
      final c = contacts[id];
      if (c != null) yield c;
    }
  }

  /// Alle Adress-Quellen einer Lieferung — in der vom Backend gelieferten
  /// Reihenfolge (nach [ContactRole], anschließend nach Quell-Id für
  /// stabile UI). Leere Liste, wenn diese Lieferung im ERP keinen Kontakt
  /// hängen hat.
  List<ContactSource> contactSourcesOf(Delivery delivery) =>
      contactSourcesByDeliveryId[delivery.id] ?? const <ContactSource>[];

  /// Alle Kanäle einer einzelnen Quelle. Leere Liste, wenn die Quelle nur
  /// einen Namensblock trägt (z. B. ein Ansprechpartner ohne Telefonnummer).
  List<ContactChannel> channelsOf(ContactSource source) =>
      contactChannelsBySourceId[source.id] ?? const <ContactChannel>[];

  /// Wie [contactSourcesOf], aber Quellen mit identischem Namensblock UND
  /// identischer Channel-Liste sind zu einem [MergedContactSource] mit
  /// Multi-Rollen-Header zusammengeführt. Das eliminiert die typische
  /// Doppelung „Belegadresse + Kundenstamm" bei Belegen, deren
  /// `Belegkopf.AdressId` ohnehin auf die Kunden-Stammadresse zeigt.
  ///
  /// Identity-Fingerprint: alle Namensfelder (Anrede / Titel / Name1..3 /
  /// Abteilung / Funktion) plus die nach (kind, position) sortierten
  /// (kind, value)-Paare. Zwei Quellen mit identischem Namen, aber
  /// abweichenden Channels werden NICHT gemerged — das wäre fachlich
  /// falsch (zwei verschiedene Kontaktdatensätze derselben Person).
  List<MergedContactSource> mergedContactSourcesOf(Delivery delivery) {
    final sources = contactSourcesOf(delivery);
    if (sources.isEmpty) return const <MergedContactSource>[];

    // Reihenfolge der Erstauftritte merken — die Backend-Sortierung
    // (Quellen nach Rolle aufsteigend) bestimmt damit auch die Reihenfolge
    // der Merge-Gruppen in der UI.
    final order = <String>[];
    final byKey = <String, List<ContactSource>>{};
    for (final s in sources) {
      final key = _identityKey(s, channelsOf(s));
      if (!byKey.containsKey(key)) {
        order.add(key);
        byKey[key] = <ContactSource>[];
      }
      byKey[key]!.add(s);
    }

    return [
      for (final key in order) _buildMerged(byKey[key]!),
    ];
  }

  /// Fingerprint einer Quelle: Namensblock + alle (kind, position, value)-
  /// Tripel. Vorab nach (kind-Index, position) sortiert, damit semantisch
  /// gleiche Quellen unabhängig von der Speicher-Reihenfolge denselben
  /// Schlüssel bekommen.
  String _identityKey(ContactSource s, List<ContactChannel> channels) {
    final namePart = [
      s.anrede ?? '',
      s.titel ?? '',
      s.name1 ?? '',
      s.name2 ?? '',
      s.name3 ?? '',
      s.abteilung ?? '',
      s.funktion ?? '',
    ].join('|');
    final sortedChannels = List<ContactChannel>.of(channels)
      ..sort((a, b) {
        final byKind = a.kind.index.compareTo(b.kind.index);
        if (byKind != 0) return byKind;
        return a.position.compareTo(b.position);
      });
    final channelPart = sortedChannels
        .map((c) => '${c.kind.name}:${c.position}:${c.value}')
        .join('|');
    return '$namePart||$channelPart';
  }

  MergedContactSource _buildMerged(List<ContactSource> group) {
    // Namensblock + Channels von der ersten Quelle übernehmen — alle Quellen
    // in der Gruppe sind per Identity-Key garantiert deckungsgleich.
    final first = group.first;
    final roles = group.map((s) => s.role).toList()
      ..sort((a, b) => a.index.compareTo(b.index));
    return MergedContactSource(
      roles: roles,
      anrede: first.anrede,
      titel: first.titel,
      name1: first.name1,
      name2: first.name2,
      name3: first.name3,
      abteilung: first.abteilung,
      funktion: first.funktion,
      channels: channelsOf(first),
    );
  }

  Article? articleOf(String articleId) => articles[articleId];

  Warehouse? warehouseOf(String warehouseId) => warehouses[warehouseId];

  List<DeliveryNote> notesOf(String deliveryId) =>
      notesByDeliveryId[deliveryId] ?? const <DeliveryNote>[];

  /// Aktuelle Betrags-Gutschrift dieser Lieferung, oder `null`.
  DeliveryCredit? creditOf(String deliveryId) =>
      creditsByDeliveryId[deliveryId];

  /// Gesetzter Service-Wert dieser Lieferung für einen Service, oder `null`.
  DeliveryServiceValue? serviceValueOf(String deliveryId, String serviceId) =>
      serviceValuesByDeliveryId[deliveryId]?[serviceId];

  /// Alle Attachment-IDs, die von Foto-Notizen dieser Tour referenziert
  /// werden — die Menge der „noch gültigen" Bilder. Dient dem Cache-Pruning
  /// (`AttachmentCache.retainOnly`): gecachte Vorschauen zu IDs, die hier
  /// nicht (mehr) vorkommen, gehören zu gelöschten Notizen und dürfen weg.
  Set<String> get referencedAttachmentIds {
    final ids = <String>{};
    for (final notes in notesByDeliveryId.values) {
      for (final n in notes) {
        final attachment = n.imageAttachment;
        if (attachment != null) ids.add(attachment);
      }
    }
    return ids;
  }

  bool isArticleScannable(String articleId) =>
      articles[articleId]?.scannable ?? false;

  /// Nicht-scanbare Positionen einer Lieferung (Dienstleistung / Pauschale /
  /// Fracht — `article.scannable == false`). Entfernte Zeilen sind hier
  /// ausgefiltert, weil eine entfernte Dienstleistung den Belade-/Anfahrt-
  /// Hinweis nicht mehr rechtfertigt.
  ///
  /// Diese Positionen werden in der Beladen-Phase **nicht gescannt**, sind
  /// aber fachlich der Grund, warum eine Lieferung ohne scanbare Ware (reine
  /// Dienstleistung) trotzdem angefahren werden muss.
  Iterable<DeliveryItem> nonScannableItems(Delivery delivery) sync* {
    for (final it in delivery.items) {
      if (it.isRemoved) continue;
      if (isArticleScannable(it.articleId)) continue;
      yield it;
    }
  }

  /// `true`, wenn die Lieferung mindestens eine nicht-scanbare Position
  /// (Dienstleistung / Pauschale) trägt — Basis für den Dienstleistungs-
  /// Hinweis in der Beladen-Ansicht.
  bool hasServiceItems(Delivery delivery) =>
      nonScannableItems(delivery).isNotEmpty;

  // ─── Lager-Aufteilung in der Beladen-Phase ───────────────────────────
  //
  // Der Fahrer startet standardmäßig im Standardlager (`Warehouse.isStandard`).
  // Filialen werden separat angefahren — sie blockieren NICHT den Übergang
  // in die Auslieferungs-Phase. Eine Lieferung gilt deshalb als „fertig
  // beladen", sobald **alle scanbaren Standardlager-Items** durch sind;
  // Filial-Items werden in der UI sichtbar gekennzeichnet, damit der
  // Fahrer weiß, dass er noch eine zweite Station ansteuern muss.

  bool _isStandard(String warehouseId) =>
      warehouseOf(warehouseId)?.isStandard ?? false;

  bool _isExternal(String warehouseId) {
    final w = warehouseOf(warehouseId);
    return w != null && !w.isStandard;
  }

  /// Iterator über die scanbaren Items einer Lieferung. `includeRemoved`
  /// kontrolliert, ob entfernte Positionen Teil der Iteration sind:
  ///
  /// * `false` (default) — für Status-Berechnungen (`standardWarehouseLoadingDone`,
  ///   `hasExternalWarehouseItems`, …). Entfernte Positionen blockieren
  ///   sonst „Fertig"-Marker oder triggern fälschlich Filial-Hinweise.
  /// * `true` — für die UI-Anzeige (`itemsGroupedByWarehouse`), damit der
  ///   Fahrer entfernte Items als durchgestrichene Zeilen weiterhin sieht
  ///   und sie ggf. wiederherstellen kann.
  Iterable<DeliveryItem> _activeScannableItems(
    Delivery delivery, {
    bool includeRemoved = false,
  }) sync* {
    for (final it in delivery.items) {
      if (!includeRemoved && it.isRemoved) continue;
      if (!isArticleScannable(it.articleId)) continue;
      yield it;
    }
  }

  /// Standardlager-Beladung dieser Lieferung ist erledigt: jedes scanbare,
  /// nicht-entfernte Item aus dem Standardlager ist `done`. Lieferungen
  /// ohne Standardlager-Items (= alles Filiale) sind trivial fertig —
  /// im Standardlager ist dann nichts zu tun.
  bool standardWarehouseLoadingDone(Delivery delivery) {
    return _activeScannableItems(delivery)
        .where((it) => _isStandard(it.warehouseId))
        .every((it) => it.isDone);
  }

  /// Lieferung enthält mindestens ein noch relevantes Filial-Item.
  /// „Relevant" = scanbar + nicht entfernt; ob das Item schon gescannt ist
  /// oder nicht spielt für diese Markierung keine Rolle (entscheidend ist
  /// nur, dass der Fahrer ein zusätzliches Lager anfahren muss).
  bool hasExternalWarehouseItems(Delivery delivery) {
    return _activeScannableItems(delivery).any(
      (it) => _isExternal(it.warehouseId),
    );
  }

  /// Filial-Items, die noch nicht beladen wurden — gedacht für die
  /// Auslieferungs-Übersicht: dort soll der Fahrer auf einen Blick sehen,
  /// dass er *vor* der Anfahrt zum Kunden noch ein zweites Lager ansteuern
  /// muss, und welche Artikel ihn dort erwarten.
  ///
  /// Item-Filter: scanbar + nicht entfernt + Filiale + `!isDone`. Items
  /// mit Status `held` zählen ebenfalls als „nicht geholt", weil das
  /// Warenholen noch aussteht.
  ///
  /// Sortierung: Lager alphabetisch, innerhalb des Lagers nach
  /// `belegzeilenNr` aufsteigend — stabile Reihenfolge zwischen Builds.
  List<({Warehouse warehouse, List<DeliveryItem> items})>
      pendingExternalWarehouseGroups(Delivery delivery) {
    final byWarehouseId = <String, List<DeliveryItem>>{};
    for (final it in _activeScannableItems(delivery)) {
      if (!_isExternal(it.warehouseId)) continue;
      if (it.isDone) continue;
      byWarehouseId.putIfAbsent(it.warehouseId, () => []).add(it);
    }
    final groups = <({Warehouse warehouse, List<DeliveryItem> items})>[];
    byWarehouseId.forEach((warehouseId, items) {
      final w = warehouseOf(warehouseId);
      if (w == null) return;
      items.sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr));
      groups.add((warehouse: w, items: items));
    });
    groups.sort((a, b) => a.warehouse.name.compareTo(b.warehouse.name));
    return groups;
  }

  /// `true`, wenn die Lieferung noch mindestens einen offenen
  /// Filial-Artikel hat (= Fahrer muss zuerst in die Filiale).
  bool hasPendingExternalWarehouseItems(Delivery delivery) {
    for (final it in _activeScannableItems(delivery)) {
      if (!_isExternal(it.warehouseId)) continue;
      if (!it.isDone) return true;
    }
    return false;
  }

  /// Eindeutige Filial-Namen dieser Lieferung — für Badges /
  /// Sektions-Header in der UI. Sortiert nach Lager-Name, damit die
  /// Reihenfolge stabil bleibt zwischen Builds.
  List<String> externalWarehouseLabels(Delivery delivery) {
    final names = <String>{};
    for (final it in _activeScannableItems(delivery)) {
      if (!_isExternal(it.warehouseId)) continue;
      final w = warehouseOf(it.warehouseId);
      if (w != null) names.add(w.name);
    }
    final list = names.toList()..sort();
    return list;
  }

  /// Gruppiert die scanbaren Items einer Lieferung nach Warehouse-Id —
  /// Standardlager-Eintrag (sofern vorhanden) immer zuerst, danach
  /// Filiale alphabetisch nach Lager-Name. Items innerhalb einer
  /// Gruppe sind nach `belegzeilenNr` aufsteigend sortiert.
  List<({Warehouse warehouse, List<DeliveryItem> items})>
      itemsGroupedByWarehouse(Delivery delivery) {
    final byWarehouseId = <String, List<DeliveryItem>>{};
    // Entfernte Items bleiben in der UI sichtbar (durchgestrichen) und
    // können dort über das Aktions-Menü wiederhergestellt werden — der
    // Status-Pfad (`standardWarehouseLoadingDone` etc.) ignoriert sie
    // trotzdem, weil die jeweiligen Helper ohne `includeRemoved` laufen.
    for (final it in _activeScannableItems(delivery, includeRemoved: true)) {
      byWarehouseId.putIfAbsent(it.warehouseId, () => []).add(it);
    }
    for (final list in byWarehouseId.values) {
      list.sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr));
    }

    // In zwei Buckets aufteilen, damit der Aufrufer Standard zuerst sieht.
    final standard = <({Warehouse warehouse, List<DeliveryItem> items})>[];
    final external = <({Warehouse warehouse, List<DeliveryItem> items})>[];
    byWarehouseId.forEach((warehouseId, items) {
      final w = warehouseOf(warehouseId);
      if (w == null) return; // Defensive: defekte Stammdaten ignorieren
      final group = (warehouse: w, items: items);
      if (w.isStandard) {
        standard.add(group);
      } else {
        external.add(group);
      }
    });
    external.sort((a, b) => a.warehouse.name.compareTo(b.warehouse.name));
    return [...standard, ...external];
  }

  /// Neues Aggregat mit ausgetauschten/erweiterten Listen — gedacht für
  /// Bloc-Reducer (Reorder, Assign-Car etc.), die das ganze Aggregat
  /// behalten und nur ein paar Lieferungen austauschen wollen.
  TourDetails copyWith({
    Tour? tour,
    List<Delivery>? deliveries,
    Map<String, List<DeliveryNote>>? notesByDeliveryId,
    Map<String, DeliveryCredit>? creditsByDeliveryId,
    Map<String, Map<String, DeliveryServiceValue>>? serviceValuesByDeliveryId,
  }) {
    return TourDetails(
      tour: tour ?? this.tour,
      deliveries: deliveries ?? this.deliveries,
      customers: customers,
      contacts: contacts,
      articles: articles,
      warehouses: warehouses,
      notesByDeliveryId: notesByDeliveryId ?? this.notesByDeliveryId,
      creditsByDeliveryId: creditsByDeliveryId ?? this.creditsByDeliveryId,
      services: services,
      serviceValuesByDeliveryId:
          serviceValuesByDeliveryId ?? this.serviceValuesByDeliveryId,
      contactSourcesByDeliveryId: contactSourcesByDeliveryId,
      contactChannelsBySourceId: contactChannelsBySourceId,
    );
  }

  /// Ersetzt eine einzelne Lieferung im Aggregat. Reihenfolge bleibt erhalten.
  TourDetails replaceDelivery(Delivery updated) {
    final next = List<Delivery>.of(deliveries);
    final idx = next.indexWhere((d) => d.id == updated.id);
    if (idx == -1) return this;
    next[idx] = updated;
    return copyWith(deliveries: next);
  }
}
