import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import {
  BURNED_ROAD_ID,
  SOUTH_GATE_ID,
} from '../database/seeds/vertical-slice.constants';
import { LocationConnection } from '../world/entities/location-connection.entity';
import { LocationDefinition } from '../world/entities/location-definition.entity';
import { Clock } from './clock';
import { Travel } from './entities/travel.entity';
import { TravelDomainError } from './travel.errors';
import { TravelService } from './travel.service';
import { TravelStatus } from './travel-status.enum';

const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const CONNECTION_ID = '30000000-0000-4000-8000-000000000001';
const TRAVEL_ID = '40000000-0000-4000-8000-000000000001';
const NOW = new Date('2026-08-18T10:00:00.000Z');

interface FakeState {
  characters: Character[];
  locations: LocationDefinition[];
  connections: LocationConnection[];
  travels: Travel[];
}

class FakeRepository<T extends { id: string }> {
  constructor(
    private readonly state: FakeState,
    private readonly target: EntityTarget<T>,
    private readonly inTransaction: boolean,
    private readonly dataSource: FakeDataSource,
  ) {}

  findOne(options: {
    where: Partial<T>;
    lock?: { mode: string };
  }): Promise<T | null> {
    if (options.lock) {
      if (!this.inTransaction) {
        throw new Error('Pessimistic locks require a transaction');
      }
      this.dataSource.locks.push({
        target: this.target,
        mode: options.lock.mode,
      });
    }

    return Promise.resolve(
      this.rows().find((row) => this.matches(row, options.where)) ?? null,
    );
  }

  findOneBy(where: Partial<T>): Promise<T | null> {
    return Promise.resolve(
      this.rows().find((row) => this.matches(row, where)) ?? null,
    );
  }

  create(values: Partial<T>): T {
    return { ...values } as T;
  }

  save(entity: T): Promise<T> {
    if (this.dataSource.failSaveTarget === this.target) {
      throw new Error(`Failed to save ${this.targetName()}`);
    }

    if (!entity.id) {
      entity.id = TRAVEL_ID;
    }

    const rows = this.rows();
    const index = rows.findIndex((row) => row.id === entity.id);
    if (index === -1) {
      rows.push(entity);
    } else {
      rows[index] = entity;
    }
    return Promise.resolve(entity);
  }

  private rows(): T[] {
    if (this.target === Character) {
      return this.state.characters as T[];
    }
    if (this.target === LocationDefinition) {
      return this.state.locations as T[];
    }
    if (this.target === LocationConnection) {
      return this.state.connections as T[];
    }
    if (this.target === Travel) {
      return this.state.travels as T[];
    }
    throw new Error(`Unsupported repository ${this.targetName()}`);
  }

  private matches(row: T, where: Partial<T>): boolean {
    return Object.entries(where).every(
      ([key, value]) => row[key as keyof T] === value,
    );
  }

  private targetName(): string {
    return typeof this.target === 'function'
      ? this.target.name
      : 'EntitySchema';
  }
}

class FakeEntityManager {
  constructor(
    private readonly state: FakeState,
    private readonly dataSource: FakeDataSource,
  ) {}

  getRepository<T extends { id: string }>(target: EntityTarget<T>) {
    return new FakeRepository(this.state, target, true, this.dataSource);
  }
}

class FakeDataSource {
  readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
  failSaveTarget?: EntityTarget<unknown>;

  constructor(public state: FakeState) {}

  getRepository<T extends { id: string }>(target: EntityTarget<T>) {
    return new FakeRepository(this.state, target, false, this);
  }

  async transaction<T>(
    work: (manager: EntityManager) => Promise<T>,
  ): Promise<T> {
    const draft = structuredClone(this.state);
    const result = await work(
      new FakeEntityManager(draft, this) as unknown as EntityManager,
    );
    this.state = draft;
    return result;
  }
}

function location(id: string, key: string, name: string): LocationDefinition {
  return {
    id,
    key,
    name,
    description: `${name} description`,
    regionKey: 'ashen-fields',
    minRecommendedLevel: 1,
    maxRecommendedLevel: 2,
    dangerLevel: 1,
    isSafe: id === SOUTH_GATE_ID,
    huntingEnabled: id === BURNED_ROAD_ID,
    artworkPath: `/assets/locations/${key}.webp`,
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    updatedAt: new Date('2026-08-18T09:00:00.000Z'),
    characters: [],
    outgoingConnections: [],
    incomingConnections: [],
  };
}

function createState(): FakeState {
  const southGate = location(
    SOUTH_GATE_ID,
    'south-gate',
    'S\u00fcdtor von Graufurt',
  );
  const burnedRoad = location(
    BURNED_ROAD_ID,
    'burned-road',
    'Verbrannte Stra\u00dfe',
  );
  const character: Character = {
    id: CHARACTER_ID,
    name: 'Aric Duskwalker',
    level: 1,
    experience: 0,
    baseHp: 100,
    baseAttack: 6,
    currentHp: 100,
    currentLocationId: SOUTH_GATE_ID,
    currentLocation: southGate,
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    updatedAt: new Date('2026-08-18T09:00:00.000Z'),
  };
  const connection = {
    id: CONNECTION_ID,
    fromLocationId: SOUTH_GATE_ID,
    toLocationId: BURNED_ROAD_ID,
    travelDurationSeconds: 10,
    ambushChance: '0.0500',
    enabled: true,
    fromLocation: southGate,
    toLocation: burnedRoad,
  } as LocationConnection;

  return {
    characters: [character],
    locations: [southGate, burnedRoad],
    connections: [connection],
    travels: [],
  };
}

function activeTravel(arrivesAt: Date): Travel {
  return {
    id: TRAVEL_ID,
    characterId: CHARACTER_ID,
    originLocationId: SOUTH_GATE_ID,
    targetLocationId: BURNED_ROAD_ID,
    startedAt: new Date('2026-08-18T09:59:50.000Z'),
    arrivesAt,
    status: TravelStatus.TRAVELLING,
    createdAt: new Date('2026-08-18T09:59:50.000Z'),
  } as Travel;
}

function createService(state = createState()) {
  const dataSource = new FakeDataSource(state);
  const clock: Clock = { now: () => new Date(NOW) };
  const service = new TravelService(dataSource as unknown as DataSource, clock);
  return { dataSource, service };
}

describe('TravelService', () => {
  it('starts travel for an enabled directed connection', async () => {
    const { dataSource, service } = createService();

    await expect(
      service.startTravel(CHARACTER_ID, BURNED_ROAD_ID),
    ).resolves.toEqual({
      status: TravelStatus.TRAVELLING,
      originLocation: {
        id: SOUTH_GATE_ID,
        key: 'south-gate',
        name: 'S\u00fcdtor von Graufurt',
      },
      targetLocation: {
        id: BURNED_ROAD_ID,
        key: 'burned-road',
        name: 'Verbrannte Stra\u00dfe',
      },
      startedAt: new Date('2026-08-18T10:00:00.000Z'),
      arrivesAt: new Date('2026-08-18T10:00:10.000Z'),
    });
    expect(dataSource.state.travels).toHaveLength(1);
    expect(dataSource.state.travels[0]).toMatchObject({
      characterId: CHARACTER_ID,
      originLocationId: SOUTH_GATE_ID,
      targetLocationId: BURNED_ROAD_ID,
      status: TravelStatus.TRAVELLING,
    });
    expect(dataSource.locks).toEqual([
      { target: Character, mode: 'pessimistic_write' },
      { target: Travel, mode: 'pessimistic_write' },
    ]);
  });

  it('rejects a target without an enabled connection', async () => {
    const state = createState();
    state.connections[0].enabled = false;
    const { dataSource, service } = createService(state);

    await expect(
      service.startTravel(CHARACTER_ID, BURNED_ROAD_ID),
    ).rejects.toMatchObject<Partial<TravelDomainError>>({
      code: 'INVALID_TRAVEL_TARGET',
    });
    expect(dataSource.state.travels).toEqual([]);
  });

  it('derives arrivesAt from the injected clock and connection duration', async () => {
    const { dataSource, service } = createService();

    const result = await service.startTravel(CHARACTER_ID, BURNED_ROAD_ID);

    expect(result.startedAt).toEqual(new Date('2026-08-18T10:00:00.000Z'));
    expect(result.arrivesAt).toEqual(new Date('2026-08-18T10:00:10.000Z'));
    expect(dataSource.state.travels[0].arrivesAt).toEqual(
      new Date('2026-08-18T10:00:10.000Z'),
    );
  });

  it('rejects a second journey with a stable active-travel error', async () => {
    const state = createState();
    state.travels.push(activeTravel(new Date('2026-08-18T10:00:10.000Z')));
    const { dataSource, service } = createService(state);

    let error: unknown;
    try {
      await service.startTravel(CHARACTER_ID, BURNED_ROAD_ID);
    } catch (cause) {
      error = cause;
    }

    expect(error).toBeInstanceOf(TravelDomainError);
    if (!(error instanceof TravelDomainError)) {
      throw new Error('Expected TravelDomainError');
    }
    expect(error.getResponse()).toEqual({
      statusCode: 409,
      code: 'TRAVEL_ALREADY_ACTIVE',
      message: 'The character is already travelling.',
    });
    expect(dataSource.state.travels).toHaveLength(1);
  });

  it('returns the current active travel without exposing persistence fields', async () => {
    const state = createState();
    state.travels.push(activeTravel(new Date('2026-08-18T10:00:10.000Z')));
    const { service } = createService(state);

    await expect(service.getCurrentTravel(CHARACTER_ID)).resolves.toEqual({
      status: TravelStatus.TRAVELLING,
      originLocation: {
        id: SOUTH_GATE_ID,
        key: 'south-gate',
        name: 'S\u00fcdtor von Graufurt',
      },
      targetLocation: {
        id: BURNED_ROAD_ID,
        key: 'burned-road',
        name: 'Verbrannte Stra\u00dfe',
      },
      startedAt: new Date('2026-08-18T09:59:50.000Z'),
      arrivesAt: new Date('2026-08-18T10:00:10.000Z'),
    });
  });

  it('does not complete or move the character before arrivesAt', async () => {
    const state = createState();
    state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.001Z')));
    const { dataSource, service } = createService(state);

    await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
      status: TravelStatus.TRAVELLING,
      originLocation: {
        id: SOUTH_GATE_ID,
        key: 'south-gate',
        name: 'S\u00fcdtor von Graufurt',
      },
      targetLocation: {
        id: BURNED_ROAD_ID,
        key: 'burned-road',
        name: 'Verbrannte Stra\u00dfe',
      },
      startedAt: new Date('2026-08-18T09:59:50.000Z'),
      arrivesAt: new Date('2026-08-18T10:00:00.001Z'),
    });
    expect(dataSource.state.characters[0].currentLocationId).toBe(
      SOUTH_GATE_ID,
    );
    expect(dataSource.state.travels[0].status).toBe(TravelStatus.TRAVELLING);
  });

  it('completes due travel and updates character location atomically', async () => {
    const state = createState();
    state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.000Z')));
    const { dataSource, service } = createService(state);

    await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
      status: TravelStatus.COMPLETED,
      targetLocation: {
        id: BURNED_ROAD_ID,
        key: 'burned-road',
        name: 'Verbrannte Stra\u00dfe',
      },
    });
    expect(dataSource.state.characters[0].currentLocationId).toBe(
      BURNED_ROAD_ID,
    );
    expect(dataSource.state.travels[0].status).toBe(TravelStatus.COMPLETED);

    await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
      status: 'IDLE',
    });
    expect(dataSource.state.characters[0].currentLocationId).toBe(
      BURNED_ROAD_ID,
    );
  });

  it('rolls back both due-travel updates if either save fails', async () => {
    const state = createState();
    state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.000Z')));
    const { dataSource, service } = createService(state);
    dataSource.failSaveTarget = Character;

    await expect(service.completeTravelIfDue(CHARACTER_ID)).rejects.toThrow(
      'Failed to save Character',
    );
    expect(dataSource.state.characters[0].currentLocationId).toBe(
      SOUTH_GATE_ID,
    );
    expect(dataSource.state.travels[0].status).toBe(TravelStatus.TRAVELLING);
  });
});
