// The plan editor (Superroute OPEN-ROUTE-EDIT E10) in React Native. The editor itself is the Web SDK's
// (js/src/smap-plan.js, copied as it is to plan/smap-plan.js by tools/plan-rn.mjs): its state machine, its calls of
// the Open Platform over fetch, the distance snapshot, the credentials and its events are those of every other sMap
// SDK. What is React Native's own: the app identity read from the platform, the plan drawn through the map's layers,
// the drag of a stop found by the native map under the finger, and the ready-made panel of React Native views.
import * as React from 'react';
import { useEffect, useReducer, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { StyleProp, ViewStyle } from 'react-native';
import * as smapPlan from './plan/smap-plan';
import type {
  PlanEditor as PlanEditorType,
  PlanEditorOptions as WebPlanEditorOptions,
  PlanEditorState,
  PlanFeature,
  PlanSummary,
  PlanTextKey,
  PlanViolation,
} from './plan/smap-plan';
import { useSMap, type PlanDragStep, type SMapHandle } from './SMap';

export type {
  Plan,
  PlanVehicle,
  PlanJob,
  PlanRoute,
  PlanState,
  PlanResult,
  PlanSummary,
  PricedRoute,
  PricedStop,
  PlanViolation,
  PlanCredentials,
  PlanTokenAnswer,
  PlanChangeEvent,
  PlanEditorError,
  PlanEditorEvents,
  PlanEditorState,
  PlanEditOutcome,
  PlanSnapshotEvent,
  PlanPreview,
  InsertChoice,
  InsertChoices,
  PlanTextKey,
  PlanLanguage,
  PlanFeature,
  PlanFeatures,
  PlanDropTarget,
  PlanEditorParts,
} from './plan/smap-plan';

/** The plan editor: state, calls, undo / redo and events (the Web SDK's PlanEditor, run as it is). */
export type PlanEditor = PlanEditorType;
export const PlanEditor = smapPlan.PlanEditor;

/** The parts the map layer and the panel are made of: features, drop targets, the drag with its preview, texts. */
export const planEditorParts = smapPlan.parts;
/** The panel's texts in its 14 languages, the same as shared/plan-texts.json. */
export const planTexts = smapPlan.texts;
export const planTextKeys = smapPlan.textKeys;
export const planLanguages = smapPlan.languages;
/** The panel's language for a BCP-47 tag: one of the 14, English when none fits. */
export const planPanelLanguage = smapPlan.panelLanguage;

/** Options of a plan editor in React Native: those of every sMap SDK, without `app` (always read from the platform). */
export type PlanEditorOptions = Omit<WebPlanEditorOptions, 'app'>;

/**
 * Makes a plan editor for `map`'s app: `X-SMap-App` (and `X-SMap-App-Cert` on Android) are read from the platform
 * through the native map, never taken from the options. The options' plan is set (and priced) once it is made.
 */
export async function createPlanEditor(map: SMapHandle, options: PlanEditorOptions): Promise<PlanEditor> {
  const who = await map.appIdentity().catch(() => ({ id: null, cert: null }));
  const { plan, ...rest } = options as WebPlanEditorOptions;
  const app = who.id ? (who.cert ? { id: who.id, cert: who.cert } : who.id) : undefined;
  const smapUrl = (rest as WebPlanEditorOptions).smapUrl || map.smapUrl || undefined;
  const editor = new smapPlan.PlanEditor({ ...rest, app, smapUrl });
  if (plan) { editor.setPlan(plan).catch(() => undefined); }
  return editor;
}

/**
 * A plan editor for the map of the nearest `<SMap>` (or `map`), made once that map is ready and destroyed with the
 * component. Null until then. The options are read once.
 */
export function usePlanEditor(options: PlanEditorOptions, map?: SMapHandle | null): PlanEditor | null {
  const context = useSMap();
  const target = map === undefined ? context : map;
  const [editor, setEditor] = useState<PlanEditor | null>(null);
  const first = useRef(options);
  useEffect(() => {
    if (!target) { return undefined; }
    let made: PlanEditor | null = null;
    let gone = false;
    createPlanEditor(target, first.current).then((e) => {
      if (gone) { e.destroy(); return; }
      made = e;
      setEditor(e);
    }, () => undefined);
    return () => {
      gone = true;
      setEditor(null);
      if (made) { made.destroy(); }
    };
  }, [target]);
  return editor;
}

// ------------------------------------------------------------------ the map layer

const BORDER_COLOR = '#9ca3af';
const DRAG_COLOR = '#111827';

/** The layer ids of a plan drawn under the prefix `id`, bottom first. */
export function planLayerIds(id = 'plan') {
  return { legs: id + ':legs', starts: id + ':starts', unassigned: id + ':unassigned', borders: id + ':stop-borders', stops: id + ':stops', drag: id + ':drag' };
}

// Features with the plan's properties plus the simplestyle values the map's layers draw with.
function collection(list: PlanFeature[], style: (f: PlanFeature) => Record<string, unknown>) {
  return { type: 'FeatureCollection' as const, features: list.map((f) => ({ type: 'Feature' as const, geometry: f.geometry, properties: { ...f.properties, ...style(f) } })) };
}

/** The data of each layer for the editor's plan now. */
export function planLayerData(editor: PlanEditor, id = 'plan', colors?: string[]) {
  const f = smapPlan.parts.planFeatures(editor, id, colors);
  return {
    legs: collection(f.legs, (x) => ({ stroke: x.properties.color })),
    starts: collection(f.starts, (x) => ({ 'marker-color': x.properties.color, 'marker-size': 'large' })),
    unassigned: collection(f.unassigned, () => ({})),
    borders: collection(f.stops, (x) => ({ 'marker-color': x.properties.border, 'marker-size': 'large', kind: 'stop-border' })),
    stops: collection(f.stops, (x) => ({ 'marker-color': x.properties.color })),
  };
}

export interface PlanEditorLayerProps {
  editor: PlanEditor | null;
  /** Prefix of the layer ids (default 'plan'). */
  id?: string;
  /** Route colours, one per vehicle in the plan's order (repeated). */
  colors?: string[];
  /** Price the drop while a stop is dragged (default true). */
  preview?: boolean;
  /** The lowest zIndex of the plan's layers (default 0). */
  zIndex?: number;
}

/**
 * Draws the editor's plan on the map of the nearest `<SMap>` — legs, starts, numbered stops (border dark red with a
 * violation, near black when locked), unassigned jobs in grey — and lets a stop or an unassigned job be moved: long
 * press it, drag it before another stop or onto a route's line, and let go. While it is dragged, the routes the drop
 * would make are priced as a preview 250 ms after the target last changed. The map does not pan meanwhile.
 */
export function PlanEditorLayer(props: PlanEditorLayerProps): null {
  const map = useSMap();
  const { editor, id = 'plan', preview = true, zIndex = 0 } = props;
  const colorsRef = useRef(props.colors);
  colorsRef.current = props.colors;
  useEffect(() => {
    if (!map || !editor) { return undefined; }
    const ids = planLayerIds(id);
    let added = false;
    let removed = false;
    const draw = () => {
      if (removed) { return; }
      const d = planLayerData(editor, id, colorsRef.current);
      if (!added) {
        map.addLines(ids.legs, d.legs, { width: 4, zIndex });
        map.addPoints(ids.starts, d.starts, { zIndex: zIndex + 1 });
        map.addPoints(ids.unassigned, d.unassigned, { color: BORDER_COLOR, radius: 9, zIndex: zIndex + 2 });
        map.addPoints(ids.borders, d.borders, { zIndex: zIndex + 3 });
        map.addPoints(ids.stops, d.stops, { label: 'label', zIndex: zIndex + 4 });
        map.addPoints(ids.drag, { type: 'FeatureCollection', features: [] }, { color: DRAG_COLOR, radius: 8, zIndex: zIndex + 5 });
        added = true;
      } else {
        map.setData(ids.legs, d.legs);
        map.setData(ids.starts, d.starts);
        map.setData(ids.unassigned, d.unassigned);
        map.setData(ids.borders, d.borders);
        map.setData(ids.stops, d.stops);
      }
    };
    const drag = smapPlan.parts.planDrag(editor, { preview });
    const clearMarker = () => { if (added && !removed) { map.setData(ids.drag, { type: 'FeatureCollection', features: [] }); } };
    const onStep = (step: PlanDragStep) => {
      const hits = step.hits.map((properties) => ({ properties }));
      const point = { x: step.x, y: step.y };
      if (step.phase === 'start') {
        drag.start(step.stop, point);
      } else if (step.phase === 'move') {
        if (drag.dragging() !== step.stop) { drag.start(step.stop, point); }
        drag.move(hits, point);
        if (drag.moved() && added) { map.setData(ids.drag, [{ lng: step.lng, lat: step.lat }]); }
      } else if (step.phase === 'end') {
        clearMarker();
        drag.end(hits).catch(() => null);
      } else {
        clearMarker();
        drag.cancel();
      }
    };
    const remove = () => {
      if (removed) { return; }
      drag.cancel();
      map.onPlanDrag(id, null);
      editor.off('change', draw);
      editor.off('destroy', remove);
      if (added) { Object.values(ids).forEach((l) => map.removeLayer(l)); }
      removed = true;
    };
    editor.on('change', draw);
    editor.on('destroy', remove);
    map.onPlanDrag(id, onStep);
    if (editor.getPlan()) { draw(); }
    return remove;
  }, [map, editor, id, preview, zIndex]);
  return null;
}

// ------------------------------------------------------------------ the panel

/** The panel's number and time formats for a language (km with one decimal, h / min, the answer's clock time). */
export function planFormats(language: string, t: Record<PlanTextKey, string>) {
  let one: (v: number) => string;
  try {
    // Portuguese is European Portuguese, as the texts are: any pt tag writes numbers as pt-PT (never Brazilian).
    const locale = smapPlan.panelLanguage(language) === 'pt' ? 'pt-PT' : language;
    const nf = new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
    one = (v) => nf.format(v);
  } catch {
    one = (v) => v.toFixed(1);
  }
  const fill = (template: string, values: Record<string, string | number>) =>
    template.replace(/\{(\w+)\}/g, (m, k: string) => (values[k] === undefined ? m : String(values[k])));
  return {
    fill,
    distance: (m: number | null | undefined) => fill(t.km, { d: one((Number(m) || 0) / 1000) }),
    time: (s: number | null | undefined) => {
      const min = Math.round((Number(s) || 0) / 60);
      const h = Math.floor(min / 60);
      const m = min % 60;
      return h > 0 ? fill(t.hours, { h, m: (m < 10 ? '0' : '') + m }) : fill(t.minutes, { m });
    },
    // The clock time of an ISO 8601 time with offset, as the answer gives it (the plan's own time zone).
    clock: (iso: string | null | undefined) => { const r = /T(\d\d:\d\d)/.exec(String(iso || '')); return r ? r[1] : ''; },
  };
}

export interface PlanPanelProps {
  editor: PlanEditor | null;
  /** Any BCP-47 tag (default English); one of the 14 languages, English when none fits. */
  language?: string;
  /** The app's own texts by key, over the language's. */
  labels?: Partial<Record<PlanTextKey, string>>;
  /** Route colours, one per vehicle in the plan's order (as given to `<PlanEditorLayer>`). */
  colors?: string[];
  style?: StyleProp<ViewStyle>;
}

function colorOf(state: PlanEditorState, vehicleId: string, colors?: string[]) {
  const list = colors || smapPlan.parts.colors;
  const i = Math.max(0, (state.plan?.vehicles || []).findIndex((v) => String(v.id) === vehicleId));
  return list[i % list.length];
}

function Button(props: { label: string; onPress: () => unknown; disabled?: boolean; testID?: string }) {
  const { label, onPress, disabled, testID } = props;
  return (
    <Pressable
      testID={testID}
      accessibilityRole="button"
      accessibilityState={{ disabled: !!disabled }}
      disabled={disabled}
      onPress={() => { const p = onPress(); if (p && typeof (p as Promise<unknown>).catch === 'function') { (p as Promise<unknown>).catch(() => undefined); } }}
      style={[s.button, disabled ? s.disabled : null]}
    >
      <Text style={s.buttonText}>{label}</Text>
    </Pressable>
  );
}

/**
 * The ready-made panel of the plan editor, made of React Native views: Undo, Redo, Refine and the busy status; the
 * error with its code and reason; the refine note; the totals (and the preview's while a stop is dragged on the
 * map); each route with its colour, totals, violations and stops (number, id, arrival, violations, Lock / Unlock,
 * Remove); the unassigned jobs (Place); the positions an insert answered (Choose). Long-press a stop or a job to pick
 * it up, then tap a stop to drop it before that stop, a route's heading to drop it last on that route, or the
 * unassigned heading to take it off its route. Put it in a `<Tool>` to have it on the map.
 */
export function PlanPanel(props: PlanPanelProps): React.ReactElement | null {
  const { editor, language = 'en', labels, colors, style } = props;
  const [, rerender] = useReducer((n: number) => n + 1, 0);
  const [picked, setPicked] = useState<string | null>(null);
  useEffect(() => {
    if (!editor) { return undefined; }
    const on = () => rerender();
    const types = ['change', 'busy', 'preview'] as const;
    types.forEach((t) => editor.on(t, on));
    return () => { types.forEach((t) => editor.off(t, on)); };
  }, [editor]);
  if (!editor) { return null; }
  const t = smapPlan.parts.textsFor(language, labels);
  const fmt = planFormats(language, t);
  const st = editor.state();
  const ruleText = (v: PlanViolation) => t[('rule.' + v.rule) as PlanTextKey] || v.message || v.rule;
  const drop = (run: () => unknown) => { setPicked(null); return run(); };

  const totals = (key: string, summary: Partial<PlanSummary> & { violation_count?: number }, stops: number) => {
    const violations = summary.violations || summary.violation_count;
    return (
      <View style={s.totals} testID={key}>
        <Text style={s.small}>{t.distance + ': ' + fmt.distance(summary.distance_meters)}</Text>
        <Text style={s.small}>{t.drivingTime + ': ' + fmt.time(summary.duration_seconds)}</Text>
        <Text style={s.small}>{t.stops + ': ' + String(stops)}</Text>
        {violations ? <Text style={[s.small, s.bad]}>{t.violations + ': ' + String(violations)}</Text> : null}
        {summary.unreachable_legs ? <Text style={[s.small, s.bad]}>{t.unreachable + ': ' + String(summary.unreachable_legs)}</Text> : null}
      </View>
    );
  };

  const priced: Record<string, NonNullable<PlanEditorState['result']>['routes'][number]> = {};
  (st.result?.routes || []).forEach((r) => { priced[r.vehicle_id] = r; });
  const summary = st.result?.summary;

  return (
    <View style={[s.panel, style]} accessibilityLabel={t.editor} testID="smap-plan-panel">
      <View style={s.bar}>
        <Button testID="smap-plan-undo" label={t.undo} disabled={!st.canUndo} onPress={() => editor.undo()} />
        <Button testID="smap-plan-redo" label={t.redo} disabled={!st.canRedo} onPress={() => editor.redo()} />
        <Button testID="smap-plan-refine" label={t.refine} disabled={!st.plan} onPress={() => editor.refine()} />
        <Text style={s.small} accessibilityLiveRegion="polite" testID="smap-plan-status">{st.busy ? t.busy : ''}</Text>
      </View>
      {st.error ? (
        <View style={s.error} accessibilityRole="alert" testID="smap-plan-error">
          <Text style={[s.small, s.bad, s.grow]}>
            {t.error + (st.error.code ? ' (' + st.error.code + (st.error.reason ? ', ' + st.error.reason : '') + ')' : '')}
          </Text>
          {/* As the Web SDK's panel: the error is taken off the editor's state. */}
          <Button label={t.close} onPress={() => { (editor as unknown as { _error: unknown })._error = null; rerender(); }} />
        </View>
      ) : null}
      {st.last && st.last.kind === 'refine' && st.last.answer ? (
        <Text style={s.small} testID="smap-plan-note">
          {(st.last.answer as { changed?: boolean; moved?: number }).changed
            ? fmt.fill(t.refined, { n: (st.last.answer as { moved?: number }).moved || 0 })
            : t.notRefined}
        </Text>
      ) : null}
      {st.plan ? (
        <>
          <View style={s.section}>
            <Text style={s.heading}>{t.total}</Text>
            {summary ? totals('smap-plan-total', summary, summary.tasks !== undefined ? summary.tasks : 0) : <Text style={s.small}>{t.pending}</Text>}
            {st.preview && st.preview.result && st.preview.result.summary ? (
              <View testID="smap-plan-preview">
                <Text style={s.subheading}>{t.preview}</Text>
                {totals('smap-plan-preview-totals', st.preview.result.summary, st.preview.result.summary.tasks || 0)}
              </View>
            ) : null}
          </View>
          {st.plan.routes.map((r) => {
            const p = priced[r.vehicle_id];
            const byId: Record<string, NonNullable<typeof p>['stops'][number]> = {};
            (p?.stops || []).forEach((x) => { if (x.type === 'job' && x.id) { byId[x.id] = x; } });
            return (
              <View key={r.vehicle_id} style={s.section} testID={'smap-plan-route-' + r.vehicle_id}>
                <Pressable
                  disabled={!picked}
                  onPress={() => picked && drop(() => editor.dropStop(picked, r.vehicle_id, r.stops.length + 1).catch(() => null))}
                  style={[s.routeHead, picked ? s.target : null]}
                >
                  <View style={[s.swatch, { backgroundColor: colorOf(st, r.vehicle_id, colors) }]} />
                  <Text style={s.heading}>{r.vehicle_id}</Text>
                </Pressable>
                {p ? totals('smap-plan-route-totals-' + r.vehicle_id, { distance_meters: p.distance_meters, duration_seconds: p.duration_seconds, violation_count: p.violation_count, unreachable_legs: p.unreachable_legs }, r.stops.length) : null}
                {p && p.violations && p.violations.length ? p.violations.map((v, k) => <Text key={k} style={[s.small, s.bad]}>{ruleText(v)}</Text>) : null}
                {!r.stops.length ? <Text style={s.small}>{t.noStops}</Text> : null}
                {r.stops.map((id, i) => {
                  const info = byId[id];
                  const locked = editor.isLocked(id);
                  const marks: string[] = [];
                  if (info && info.reachable === false) { marks.push(t['rule.unreachable']); }
                  (info?.violations || []).forEach((v) => marks.push(ruleText(v)));
                  const clock = info && info.arrival ? fmt.clock(info.arrival) : '';
                  return (
                    <Pressable
                      key={id}
                      testID={'smap-plan-stop-' + id}
                      onLongPress={() => setPicked(id)}
                      onPress={() => {
                        if (!picked) { return; }
                        if (picked === id) { setPicked(null); return; }
                        drop(() => editor.dropStop(picked, r.vehicle_id, i + 1).catch(() => null));
                      }}
                      style={[s.stop, locked ? s.locked : null, picked === id ? s.picked : null, picked && picked !== id ? s.target : null]}
                    >
                      <Text style={s.seq}>{String(i + 1)}</Text>
                      <Text style={[s.small, s.grow]}>{id}</Text>
                      {clock ? <Text style={s.small} accessibilityLabel={fmt.fill(t.arrival, { time: clock })}>{clock}</Text> : null}
                      {marks.length ? <Text style={[s.small, s.bad]}>{marks.join(', ')}</Text> : null}
                      <Button label={locked ? t.unlock : t.lock} onPress={() => (locked ? editor.unlock(id) : editor.lock(id))} />
                      <Button label={t.remove} onPress={() => editor.remove([id])} />
                    </Pressable>
                  );
                })}
              </View>
            );
          })}
          <View style={s.section} testID="smap-plan-unassigned">
            <Pressable
              disabled={!picked}
              onPress={() => {
                if (!picked) { return; }
                const id = picked;
                drop(() => (st.plan!.unassigned.indexOf(id) >= 0 ? null : editor.remove([id]).catch(() => null)));
              }}
              style={picked ? s.target : null}
            >
              <Text style={s.heading}>{t.unassigned + ' (' + st.plan.unassigned.length + ')'}</Text>
            </Pressable>
            {st.plan.unassigned.map((id) => (
              <Pressable
                key={id}
                testID={'smap-plan-job-' + id}
                onLongPress={() => setPicked(id)}
                onPress={() => { if (picked === id) { setPicked(null); } }}
                style={[s.stop, picked === id ? s.picked : null]}
              >
                <Text style={[s.small, s.grow]}>{id}</Text>
                <Button label={t.place} onPress={() => editor.insert(id)} />
              </Pressable>
            ))}
          </View>
          {st.choices && st.choices.length ? (
            <View style={s.section} testID="smap-plan-choices">
              {st.choices.map((item) => (
                <View key={item.job}>
                  <Text style={s.heading}>{fmt.fill(t.choicesFor, { job: item.job })}</Text>
                  {!item.placed && !item.choices.length ? <Text style={s.small}>{t.notPlaced}</Text> : null}
                  {item.choices.map((c, i) => {
                    const chosen = item.chosen === i;
                    return (
                      <View key={i} style={[s.stop, c.feasible ? null : s.infeasible]}>
                        <View style={s.grow}>
                          <Text style={s.small}>{fmt.fill(t.choice, { vehicle: c.vehicle_id, n: c.position })}</Text>
                          <Text style={s.small}>{fmt.fill(t.added, { distance: fmt.distance(c.added_distance_meters), time: fmt.time(c.added_duration_seconds) })}</Text>
                          {c.arrival ? <Text style={s.small}>{fmt.fill(t.arrival, { time: fmt.clock(c.arrival) })}</Text> : null}
                          {!c.feasible && c.violations && c.violations.length ? <Text style={[s.small, s.bad]}>{c.violations.map(ruleText).join(', ')}</Text> : null}
                        </View>
                        <Button label={chosen ? t.chosen : t.choose} disabled={chosen} onPress={() => editor.choose(item.job, i)} />
                      </View>
                    );
                  })}
                </View>
              ))}
              <Button label={t.close} onPress={() => editor.clearChoices()} />
            </View>
          ) : null}
        </>
      ) : null}
    </View>
  );
}

const s = StyleSheet.create({
  panel: { backgroundColor: '#ffffff', borderRadius: 8, padding: 8, borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(15,23,42,0.15)', maxWidth: 360 },
  bar: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 4 },
  button: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, backgroundColor: '#2563eb', marginLeft: 4 },
  buttonText: { color: '#ffffff', fontSize: 13 },
  disabled: { opacity: 0.4 },
  error: { flexDirection: 'row', alignItems: 'center', marginTop: 6 },
  section: { marginTop: 8 },
  heading: { fontSize: 14, fontWeight: '600', color: '#1e293b' },
  subheading: { fontSize: 13, fontWeight: '600', color: '#1e293b', marginTop: 4 },
  small: { fontSize: 12, color: '#1e293b', marginRight: 6 },
  bad: { color: '#b91c1c' },
  grow: { flex: 1 },
  totals: { flexDirection: 'row', flexWrap: 'wrap' },
  routeHead: { flexDirection: 'row', alignItems: 'center' },
  swatch: { width: 12, height: 12, borderRadius: 6, marginRight: 6 },
  stop: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#e2e8f0' },
  seq: { width: 22, fontSize: 12, fontWeight: '600', color: '#1e293b' },
  locked: { backgroundColor: '#f1f5f9' },
  picked: { backgroundColor: '#dbeafe' },
  target: { borderStyle: 'dashed', borderWidth: 1, borderColor: '#2563eb' },
  infeasible: { opacity: 0.6 },
});
