/**
 * Pure journey state machine spanning the California world, vehicles, and offices.
 * No render or browser types belong here; the same reducer can run on a server.
 *
 * Outdoors is always `california`. `navigate-to-city` / `return-to-california`
 * are kept so old sessions and `?one=0` callers still compile; they cannot
 * move the actor onto a metro board, because that board is not a place.
 */

import type {
  JourneyActor,
  JourneyCity,
  JourneyEvent,
  JourneyLocation,
  JourneyMode,
  JourneyOfficeId,
  JourneyRouteEndpoint,
  JourneyRouteId,
  JourneySnapshotDecodeResult,
  JourneySnapshotV1,
  JourneyState,
  JourneyTransition,
  JourneyTransitionRejection,
} from "./types.ts";

const ROUTES: ReadonlySet<string> = new Set<JourneyRouteId>([
  "la-sf-us-101",
  "la-sf-i-5",
]);

const OFFICES: Readonly<Record<JourneyOfficeId, JourneyCity>> = Object.freeze({
  "lumbridge-hq": "bay-area",
  "frontier-valley": "bay-area",
  "mateo-court": "socal",
});

const ENDPOINT_CITY: Readonly<Record<JourneyRouteEndpoint, JourneyCity>> = Object.freeze({
  "los-angeles": "socal",
  "san-francisco": "bay-area",
});

export interface CreateJourneyOptions {
  actor: JourneyActor;
  location?: JourneyLocation;
  mode?: JourneyMode;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function finiteUnit(value: unknown): value is number {
  return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
}

function isNonEmptyString(value: unknown): value is string {
  return typeof value === "string" && value.trim().length > 0;
}

function isActor(value: unknown): value is JourneyActor {
  if (!isRecord(value) || !isNonEmptyString(value.id)) return false;
  if (value.kind !== "humanoid" && value.kind !== "dog" && value.kind !== "crow") return false;
  if (typeof value.signedIn !== "boolean" || !isRecord(value.profile)) return false;
  if (!isNonEmptyString(value.profile.displayName)) return false;
  if (value.profile.face !== undefined && !isNonEmptyString(value.profile.face)) return false;
  if (value.profile.color !== undefined && !isNonEmptyString(value.profile.color)) return false;
  return true;
}

function isOfficeId(value: unknown): value is JourneyOfficeId {
  return value === "lumbridge-hq" || value === "frontier-valley" || value === "mateo-court";
}

function isLocation(value: unknown): value is JourneyLocation {
  if (!isRecord(value)) return false;
  if (value.scale === "california") return true;
  return value.scale === "office" && isOfficeId(value.officeId);
}

/**
 * V1 sessions written when Bay Area and SoCal were journey scales.
 *
 * Those scales are geography now. A stored `bay-area` / `socal` is California;
 * an office's `priorCity` is dropped because leaving an office always returns
 * to California. Unknown office ids still fail closed.
 */
function coerceLocation(value: unknown): unknown {
  if (!isRecord(value)) return value;
  if (value.scale === "bay-area" || value.scale === "socal") return { scale: "california" };
  if (value.scale === "office" && isOfficeId(value.officeId)) {
    return { scale: "office", officeId: value.officeId };
  }
  return value;
}

/** Runtime validation at persistence and multiplayer trust boundaries. */
export function isJourneyState(value: unknown): value is JourneyState {
  if (!isRecord(value) || !isActor(value.actor) || !isLocation(value.location)) return false;
  if (value.mode !== "observe" && value.mode !== "play") return false;
  if (value.vehicle !== null) {
    if (!isRecord(value.vehicle) || !isNonEmptyString(value.vehicle.vehicleId)) return false;
    if (value.mode !== "play" || value.location.scale === "office") return false;
  }
  if (value.route !== null) {
    if (!isRecord(value.route) || !ROUTES.has(String(value.route.routeId))) return false;
    if (value.route.direction !== 1 && value.route.direction !== -1) return false;
    if (!finiteUnit(value.route.progress)) return false;
  }
  return true;
}

export function createJourney(options: CreateJourneyOptions): JourneyState {
  const state: JourneyState = {
    actor: cloneActor(options.actor),
    location: options.location ? { ...options.location } : { scale: "california" },
    mode: options.mode ?? "observe",
    vehicle: null,
    route: null,
  };
  if (!isJourneyState(state)) throw new Error("journey: invalid initial actor or location");
  return state;
}

function cloneActor(actor: JourneyActor): JourneyActor {
  return { ...actor, profile: { ...actor.profile } };
}

function reject(state: JourneyState, reason: JourneyTransitionRejection): JourneyTransition {
  return { accepted: false, state, reason };
}

function accept(state: JourneyState): JourneyTransition {
  return { accepted: true, state };
}

function routeStartFor(location: JourneyLocation, direction: 1 | -1): number | null {
  if (location.scale !== "california") return null;
  return direction === 1 ? 0 : 1;
}

/**
 * Explainable pure transition. Rejections retain the exact input state object,
 * making malformed or out-of-order multiplayer events safe to ignore.
 */
export function transitionJourney(state: JourneyState, event: JourneyEvent): JourneyTransition {
  if (!isJourneyState(state)) return reject(state, "invalid-state");
  if (!isRecord(event) || !isNonEmptyString(event.type)) return reject(state, "invalid-event");

  switch (event.type) {
    case "set-mode": {
      if (event.mode !== "observe" && event.mode !== "play") return reject(state, "invalid-event");
      if (event.mode === "observe" && state.vehicle) return reject(state, "exit-vehicle-first");
      if (event.mode === state.mode) return accept(state);
      return accept({ ...state, mode: event.mode });
    }

    case "navigate-to-city": {
      if (event.city !== "bay-area" && event.city !== "socal") {
        return reject(state, "invalid-event");
      }
      if (state.location.scale === "office") return reject(state, "leave-office-first");
      if (state.vehicle) return reject(state, "exit-vehicle-first");
      return reject(state, "already-there");
    }

    case "return-to-california": {
      if (state.location.scale === "office") return reject(state, "leave-office-first");
      if (state.vehicle) return reject(state, "exit-vehicle-first");
      return reject(state, "already-there");
    }

    case "select-route": {
      if (!ROUTES.has(event.routeId) || (event.direction !== 1 && event.direction !== -1)) {
        return reject(state, "invalid-event");
      }
      const progress = routeStartFor(state.location, event.direction);
      if (progress === null) {
        return reject(state, state.location.scale === "office" ? "wrong-scale" : "wrong-city");
      }
      return accept({
        ...state,
        location: { scale: "california" },
        route: { routeId: event.routeId, direction: event.direction, progress },
      });
    }

    case "update-route-progress": {
      if (state.location.scale !== "california") return reject(state, "wrong-scale");
      if (!state.route) return reject(state, "route-required");
      if (!state.vehicle) return reject(state, "vehicle-required");
      if (!finiteUnit(event.progress)) return reject(state, "invalid-event");
      return accept({ ...state, route: { ...state.route, progress: event.progress } });
    }

    case "reach-route-endpoint": {
      if (state.location.scale !== "california") return reject(state, "wrong-scale");
      if (!state.route) return reject(state, "route-required");
      const expected: JourneyRouteEndpoint =
        state.route.direction === 1 ? "san-francisco" : "los-angeles";
      if (event.endpoint !== expected) return reject(state, "wrong-endpoint");
      const progress = event.endpoint === "san-francisco" ? 1 : 0;
      return accept({
        ...state,
        location: { scale: "california" },
        route: { ...state.route, progress },
      });
    }

    case "enter-vehicle": {
      if (state.mode !== "play") return reject(state, "observe-only");
      if (state.location.scale === "office") return reject(state, "wrong-scale");
      if (state.vehicle) return reject(state, "vehicle-occupied");
      if (!isNonEmptyString(event.vehicleId)) return reject(state, "invalid-event");
      if (state.location.scale === "california" && !state.route) {
        return reject(state, "route-required");
      }
      return accept({ ...state, vehicle: { vehicleId: event.vehicleId } });
    }

    case "exit-vehicle": {
      if (!state.vehicle) return reject(state, "vehicle-required");
      return accept({ ...state, vehicle: null });
    }

    case "enter-office": {
      if (!isOfficeId(event.officeId)) return reject(state, "invalid-event");
      if (state.location.scale !== "california") return reject(state, "wrong-scale");
      if (state.vehicle) return reject(state, "exit-vehicle-first");
      return accept({
        ...state,
        location: {
          scale: "office",
          officeId: event.officeId,
        },
      });
    }

    case "leave-office": {
      if (state.location.scale !== "office") return reject(state, "already-outside");
      return accept({ ...state, location: { scale: "california" } });
    }

    case "sign-in-actor-swap": {
      if (!isActor(event.actor) || !event.actor.signedIn) return reject(state, "invalid-event");
      return accept({ ...state, actor: cloneActor(event.actor) });
    }

    default:
      return reject(state, "invalid-event");
  }
}

/** Conventional reducer form for stores: rejected events are safe no-ops. */
export function journeyReducer(state: JourneyState, event: JourneyEvent): JourneyState {
  return transitionJourney(state, event).state;
}

/** Encode an owned deep copy so callers cannot mutate the persisted snapshot. */
export function encodeJourneySnapshot(state: JourneyState): string {
  if (!isJourneyState(state)) throw new Error("journey: cannot encode invalid state");
  const snapshot: JourneySnapshotV1 = { version: 1, state };
  return JSON.stringify(snapshot);
}

/** Decode JSON or an already-parsed network payload through strict V1 validation. */
export function decodeJourneySnapshot(payload: string | unknown): JourneySnapshotDecodeResult {
  let parsed: unknown = payload;
  if (typeof payload === "string") {
    try {
      parsed = JSON.parse(payload) as unknown;
    } catch {
      return { ok: false, error: "journey: snapshot is not valid JSON" };
    }
  }
  if (!isRecord(parsed) || parsed.version !== 1) {
    return { ok: false, error: "journey: unsupported snapshot version" };
  }
  if (!isRecord(parsed.state)) {
    return { ok: false, error: "journey: invalid snapshot state" };
  }
  const migrated = {
    ...parsed.state,
    location: coerceLocation(parsed.state.location),
  };
  if (!isJourneyState(migrated)) {
    return { ok: false, error: "journey: invalid snapshot state" };
  }
  // JSON round-tripping is intentional: it strips aliases across a reconnect boundary.
  const state = JSON.parse(JSON.stringify(migrated)) as JourneyState;
  const snapshot: JourneySnapshotV1 = { version: 1, state };
  return { ok: true, snapshot, state };
}

export function officeCity(officeId: JourneyOfficeId): JourneyCity {
  return OFFICES[officeId];
}

export function endpointCity(endpoint: JourneyRouteEndpoint): JourneyCity {
  return ENDPOINT_CITY[endpoint];
}
