import psychopy
# psychopyVersion = '2021.2.3'  # '2020.2.10'=MartinExp1Version', 2021.2.3'=nickMac version, latest nickmac version=2022.2.4
# psychopy.useVersion(psychopyVersion)
from psychopy import __version__ as psychopyVersion  # uses the computer's downloaded version

print(f"psychopy.version: {psychopyVersion}")

from psychopy import gui, visual, core, data, event, monitors  # logging, info
from psychopy.hardware import keyboard
import os
import numpy as np
from numpy.random import shuffle
import random
import copy
from datetime import datetime
from math import tan, sqrt
from kestenSTmaxVal import Staircase

'''
Updated Exp1 script.

make sure probes and trials counter are ontop of blanded mask.
Definately hangs after if trials counter and debugger are both true and console logging is left to deafult.

Fixed timing issues for concurrent probes: I've updated isi_dur_fr and pr2_fr.
I've changed the responses to use the keyboard as suggested for coder experiments, rather than imported builder code.
Hopefully reduced memory drain issues too: removed thisExp.close, put logginf controls back in.
If memory issues stl not fixed, I could try to:
1. set auto_log=False in the experiment handler: doesn't help
2. get rid of trials_counter (or use TextBox2 rather than text_stim?): doesn't help
3. set theseKeys once earlier before frame loop.: can't do this
4. check for take a break once earlier before per-frame loop.
5. manually write to csv each trial myself: commenting out all addData calls didn't help either!
#  rather than using addData.
#  see https://discourse.psychopy.org/t/crashes-after-30-mins-wavs-have-clicks-cant-use-midi-files/4311/10
6. try turning off pyglet or using glfw
'''

# todo: Exp1_fr_test doesn't use this
clock = core.Clock()  # a clock to check times from

# todo: Exp1_fr_test doesn't use this
# print(f"psychopy.version: {psychopy.__version__}")

# Ensure that relative paths start from the same directory as this script
_thisDir = os.path.dirname(os.path.abspath(__file__))
os.chdir(_thisDir)

# Monitor config from monitor centre
monitor_name = 'NickMac'  # 'NickMac' 'asus_cal' 'Asus_VG24' 'HP_24uh' 'ASUS_2_13_240Hz' 'Iiyama_2_18' 'Nick_work_laptop'

# Store info about the experiment session
expName = 'Exp1_Dec22'  # from the Builder filename that created this script

# todo: add monitor to drop down menu?
expInfo = {'1. Participant': 'nick_fr_test_20230103',
           '2. Run_number': '1',
           '3. Probe duration in frames at 240hz': [2, 50, 100],
           '4. fps': [60, 144, 240],
           '5. Probe_orientation': ['radial', 'tangent'],
           '6. Trial_counter': [False, True],
           '7. Vary_fixation': [False, True],

           # todo: Exp1_fr_test doesn't use this
           '8. Blend_off_edges': [False, True],
           '9. testing/de-bugging': [False, True],
           }

# GUI
dlg = gui.DlgFromDict(dictionary=expInfo, title=expName)
if not dlg.OK:
    core.quit()  # user pressed escape

expInfo['time'] = datetime.now().strftime("%H:%M:%S")
expInfo['date'] = datetime.now().strftime("%d/%m/%Y")

# GUI SETTINGS
participant_name = expInfo['1. Participant']
run_number = int(expInfo['2. Run_number'])
n_trials_per_stair = 3
probe_duration = int(expInfo['3. Probe duration in frames at 240hz'])
probe_ecc = 4
fps = int(expInfo['4. fps'])
orientation = expInfo['5. Probe_orientation']
use_trials_counter = eval(expInfo['6. Trial_counter'])
vary_fixation = eval(expInfo['7. Vary_fixation'])
blend_off_edges = eval(expInfo['8. Blend_off_edges'])
verbose = eval(expInfo['9. testing/de-bugging'])

# expected frame duration
expected_fr_ms = (1/fps) * 1000

# todo: Exp1_fr_test doesn't use this
# # LOGGING AND PRINTING TO SCREEN
# # # todo: try with critical logging only
# # sets psychoPy to only log critical messages
# if verbose:
#     logging.console.setLevel(logging.DEBUG)
# else:
#     logging.console.setLevel(logging.CRITICAL)

# VARIABLES
'''Distances between probes (spatially and temporally)
For 1probe condition, use separation==99.
For concurrent probes, use ISI==-1.
'''
separations = [0, 6]  # select from [0, 1, 2, 3, 6, 18, 99]
# separations = [18]  # select from [0, 1, 2, 3, 6, 18, 99]
print(f'\nseparations: {separations}')

# todo: add in code to find equivallent isi_dur_frames for different fps from double_dist?
# ISI_values = [-1, 0, 1, 2]  # select from [-1, 0, 2, 4, 6, 9, 12, 24]
ISI_values = [-1, 6]  # , 0, 1, 2]  # select from [-1, 0, 2, 4, 6, 9, 12, 24]
print(f'ISI_values: {ISI_values}')
# repeat separation values for each ISI e.g., [0, 0, 6, 6]
sep_vals_list = list(np.repeat(separations, len(ISI_values)))
print(f'sep_vals_list: {sep_vals_list}')
# ISI_vals_list cycles through ISIs e.g., [-1, 6, -1, 6]
ISI_vals_list = list(np.tile(ISI_values, len(separations)))
print(f'ISI_vals_list: {ISI_vals_list}')
# stair_names_list joins sep_vals_list and ISI_vals_list
# e.g., ['sep0_ISI-1', 'sep0_ISI6', 'sep6_ISI-1', 'sep6_ISI6']
stair_names_list = [f'sep{s}_ISI{c}' for s, c in zip(sep_vals_list, ISI_vals_list)]
print(f'stair_names_list: {stair_names_list}')
n_stairs = len(sep_vals_list)
print(f'n_stairs: {n_stairs}')

# FILENAME
filename = f'{_thisDir}{os.sep}' \
           f'{expName}{os.sep}' \
           f'{participant_name}{os.sep}' \
           f'{participant_name}_{run_number}{os.sep}' \
           f'{participant_name}_{run_number}_output'
# files are labelled as '_incomplete' unless entire script runs.
save_output_name = filename + '_incomplete'
print(f'filename: {filename}')

# Experiment Handler
thisExp = data.ExperimentHandler(name=expName,
                                 version=psychopyVersion,  # does not set anything, just saved as string for record-keeping.
                                 extraInfo=expInfo,
                                 savePickle=True,  # todo: Exp1_fr_test doesn't use this
                                 saveWideText=True,
                                 dataFileName=save_output_name,
                                 )

# COLORS AND LUMINANCE
# Lum to Color255
LumColor255Factor = 2.39538706913372
# Color255 to Color1
Color255Color1Factor = 1 / 127.5  # Color255 * Color255Color1Factor -1
# Lum to Color1
Color1LumFactor = 2.39538706913372

maxLum = 106  # 255 RGB
# minLum = 0.12  # 0 RGB
# maxColor255 = 255
# minColor255 = 0
# maxColor1 = 1
# minColor1 = -1
bgLumProp = .2
bgLum = maxLum * bgLumProp
bgColor255 = bgLum * LumColor255Factor
bgColor1 = (bgColor255 * Color255Color1Factor) - 1

# COLOUR SPACE
# todo: Exp1_fr_test doesn't use this
# todo: set to run with RGB, but still convert to rgb255 at output.
colour_space = 'rgb255'
background_col = bgColor255

# MONITOR SPEC
thisMon = monitors.Monitor(monitor_name)
this_width = thisMon.getWidth()
mon_dict = {'mon_name': monitor_name,
            'width': thisMon.getWidth(),
            'size': thisMon.getSizePix(),
            'dist': thisMon.getDistance(),
            'notes': thisMon.getNotes()}
print(f"mon_dict: {mon_dict}")

# double check using full screen in lab
display_number = 1  # 0 indexed, 1 for external display, 0 for internal
if monitor_name in ['ASUS_2_13_240Hz', 'asus_cal', 'Nick_work_laptop', 'NickMac']:
    display_number = 0
use_full_screen = True
if display_number > 0:
    use_full_screen = False
widthPix = mon_dict['size'][0]
heightPix = mon_dict['size'][1]
monitorwidth = mon_dict['width']  # monitor width in cm
viewdist = mon_dict['dist']  # viewing distance in cm
viewdistPix = widthPix / monitorwidth * viewdist
mon = monitors.Monitor(monitor_name, width=monitorwidth, distance=viewdist)
mon.setSizePix((widthPix, heightPix))

# WINDOW SPEC
win = visual.Window(monitor=mon, size=(widthPix, heightPix),
                    colorSpace=colour_space, color=bgColor255,
                    # winType='pyglet',  # I've added this to make it work on pycharm/mac
                    winType='glfw',  # todo: Exp1_fr_test doesn't use this
                    pos=[1, -1],  # pos gives position of top-left of screen
                    units='pix',
                    screen=display_number,
                    allowGUI=False,
                    fullscr=use_full_screen)
print(f"win.colorSpace: {win.colorSpace}")
print(f"win.winType: {win.winType}")

# todo: check this - is it just for apple retina screen
if monitor_name == 'NickMac':
    widthPix = widthPix / 2
    heightPix = heightPix / 2
    widthPix, heightPix = win.size
# if verbose:
print(f"check win.size: {win.size}")
print(f"widthPix: {widthPix}, hight: {heightPix}")

# todo: Exp1_fr_test doesn't use this
# '''set an ideal frame time and margin of error for dropped frames'''
# fr_sec = 1/fps
# fr_error = 1.5
# max_fr_sec = fr_error * fr_sec
# print(f"expected frames duration is {fr_sec}.  An acceptable error is {fr_error} times this.\n"
#       f"If there are frames longer than {max_fr_sec}, the trial will be re-done.")

# ELEMENTS
# fixation bull eye
fixation = visual.Circle(win, radius=2, units='pix', lineColor='white', fillColor='black', name="fixation")

# PROBEs
probeVert = [(0, 0), (1, 0), (1, 1), (2, 1), (2, -1), (1, -1),
             (1, -2), (-1, -2), (-1, -1), (0, -1)]

probe1 = visual.ShapeStim(win, vertices=probeVert, fillColor=[0, 0, 0], name='probe1',
                          lineWidth=0, opacity=1, size=1, interpolate=False)
probe2 = visual.ShapeStim(win, vertices=probeVert, fillColor=[0, 0, 0], name='probe2',
                          lineWidth=0, opacity=1, size=1, interpolate=False)
# todo: Exp1_fr_test doesn't use this
probe1.colorSpace = 'rgb255'
probe2.colorSpace = 'rgb255'
print(f"probe1.colorSpace: {probe1.colorSpace}, probe2.colorSpace: {probe2.colorSpace}")

# probe1 = visual.ShapeStim(win, vertices=probeVert, fillColor=[1.0, -1.0, 1.0],
#                           lineWidth=0, opacity=1, size=1, interpolate=False)
# probe2 = visual.ShapeStim(win, vertices=probeVert, fillColor=[-1.0, 1.0, -1.0],
#                           lineWidth=0, opacity=1, size=1, interpolate=False)

# dist_from_fix is a constant to get 4dva distance from fixation,
dist_from_fix = round((tan(np.deg2rad(probe_ecc)) * viewdistPix) / sqrt(2))

# # HARDWARE
# MOUSE - hide cursor
myMouse = event.Mouse(visible=False)

# # KEYBOARD
# todo: changed this from builder to keyboard
# todo: Exp1_fr_test doesn't use this (it uses event.BuilderKeyResponse())
kb = keyboard.Keyboard()

# TEXT TO DISPLAY (changed from textStim to TextBox2)
# todo: get rid of tabs
# INSTRUCTIONS
insturction_text = "\n\n\n\n\n\nFocus on the fixation circle at the centre of the screen.\n\n" \
                   "A small white target will briefly appear on screen,\n" \
                   "press the key related to the location of the probe:\n\n" \
                   "[4]/[Q] top-left            [5]/[W] top-right\n\n\n\n" \
                   "[1]/[A] bottom-left            [2]/[S] bottom-right.\n\n\n" \
                   "Some targets will be easier to see than others,\n" \
                   "Some will be so dim that you won't see them, so just guess!\n\n" \
                   "You don't need to think for long, respond quickly, " \
                   "but try to push press the correct key!\n\n" \
                   "Don't let your eyes wander, keep focussed on the circle in the middle throughout."
# todo: Exp1_fr_test doesn't use this - it uses TextStim
instructions = visual.TextBox2(win=win, name='instructions', text=insturction_text,
                               # font='Arial',
                               font='Open Sans',
                               letterHeight=20, color='white',
                               alignment='center',  anchor='center',
                               size=[None, None])

# Trial counter
# todo: put trials counter back to .45 of widthPix and heightPix pos
# todo: Exp1_fr_test doesn't use this - it uses TextStim
trials_counter = visual.TextBox2(win=win, name='trials_counter', text="???",
                                 # font='Arial',
                                 font='Open Sans',
                                 letterHeight=20,
                                 # default set to background colour (e.g., invisible)
                                 color=bgColor255,
                                 pos=[-widthPix * .45, -heightPix * .45])
                                 # pos=[-widthPix * .20, -heightPix * .20])
if use_trials_counter:
    # if trials counter yes, change colour to white.
    trials_counter.color = 'white'

# BREAKS
take_break = 76
total_n_trials = int(n_trials_per_stair * n_stairs)
if verbose:
    print(f"take_break every {take_break} trials.")
breaks_text = "Break\nTurn on the light and take at least 30-seconds break.\n" \
              "Keep focussed on the fixation circle in the middle of the screen.\n" \
              "Remember, if you don't see the target, just guess!"
# todo: Exp1_fr_test doesn't use this - it uses TextStim
breaks = visual.TextBox2(win=win, name='breaks', text=breaks_text,
                         # font='Arial',
                         font='Open Sans',
                         color='white',
                         alignment='center', anchor='center',
                         # pos=[0, 0],
                         # letterHeight=20, ori=0, color=[255, 255, 255],
                         # colorSpace='rgb255', opacity=1, languageStyle='LTR', depth=0.0
                         )

# END OF EXPERIMENT MESSAGE
end_of_exp_text = "You have completed this experiment.\n" \
                  "Thank you for your time.\n\n" \
                  "Press any key to return to the desktop."
# todo: Exp1_fr_test doesn't use this - it uses TextStim
end_of_exp = visual.TextBox2(win=win, name='end_of_exp', text=end_of_exp_text,
                             # font='Arial',
                             font='Open Sans',
                             alignment='center', anchor='center',
                             letterHeight=20)

# # todo: add record intervals in dlg
# frame_err_sec = win.refreshThreshold
# print(f"frame_err_sec (120%): {frame_err_sec}")
#
# # create empty variables to use later
# fr_recorded_list = []

# todo: Exp1_fr_test doesn't use this
prev_total_recorded_fr = 0
prev_total_dropped_fr = 0

# SCREEN BEFORE EXPERIMENT
while not kb.getKeys():
    fixation.setRadius(3)
    fixation.draw()
    instructions.draw()
    trials_counter.text = f"0/{total_n_trials}"
    trials_counter.draw()
    win.flip()

# freame error tollerance
frame_err_sec = win.refreshThreshold
frame_err_ms = frame_err_sec * 1000
print(f"frame_err_sec (120%): {frame_err_sec} (or {frame_err_ms}ms)")
fr_recorded_list = []

# STAIRCASE
expInfo['stair_list'] = list(range(n_stairs))
expInfo['n_trials_per_stair'] = n_trials_per_stair

stairStart = maxLum
miniVal = bgLum
maxiVal = maxLum

stairs = []
for stair_idx in expInfo['stair_list']:

    thisInfo = copy.copy(expInfo)
    thisInfo['stair_idx'] = stair_idx

    thisStair = Staircase(name=stair_names_list[stair_idx],
                          type='simple',
                          value=stairStart,
                          C=stairStart * 0.6,  # initial step size, as prop of referene stim
                          minRevs=3,
                          minTrials=n_trials_per_stair,
                          minVal=miniVal,
                          maxVal=maxiVal,
                          targetThresh=0.75,
                          extraInfo=thisInfo)
    stairs.append(thisStair)

# EXPERIMENT
trial_number = 0
for step in range(n_trials_per_stair):
    shuffle(stairs)
    for thisStair in stairs:

        # Trial, stair and step
        trial_number = trial_number + 1
        trials_counter.text = f"{trial_number}/{total_n_trials}"
        stair_idx = thisStair.extraInfo['stair_idx']
        print(f"\ntrial_number: {trial_number}, stair_idx: {stair_idx}, thisStair: {thisStair}, step: {step}")

        # condition (Seprataion, ISI)
        sep = sep_vals_list[stair_idx]
        ISI = ISI_vals_list[stair_idx]
        if verbose:
            print(f"ISI: {ISI}, sep: {sep}")

        # Luminance (staircase varies probeLum)
        probeLum = thisStair.next()
        probeColor255 = int(probeLum * LumColor255Factor)  # rgb255 are ints.
        probeColor1 = (probeColor255 * Color255Color1Factor) - 1
        # todo: Exp1_fr_test doesn't use this - it uses probe1.color
        probe1.setColor([probeColor255, probeColor255, probeColor255], colour_space)
        probe2.setColor([probeColor255, probeColor255, probeColor255], colour_space)
        if verbose:
            print(f"probeLum: {probeLum}, probeColor255: {probeColor255}, probeColor1: {probeColor1}")
            print(f"probe1.colorSpace: {probe1.colorSpace}, probe2.colorSpace: {probe2.colorSpace}")

        # PROBE LOCATION
        # # corners go ACW(!) 45=top-right, 135=top-left, 225=bottom-left, 315=bottom-right
        # todo: change to use tuple or names tuple?
        corner = random.choice([45, 135, 225, 315])
        corner_name = 'top_right'
        if corner == 135:
            corner_name = 'top_left'
        elif corner == 225:
            corner_name = 'bottom_left'
        elif corner == 315:
            corner_name = 'bottom_right'

        # # direction in which the probe jumps : CW or ACW
        # todo change to use dict or tuples?
        target_jump = random.choice([1, -1])
        if orientation == 'tangent':
            jump_dir = 'clockwise'
            if target_jump == -1:
                jump_dir = 'anticlockwise'
        else:
            jump_dir = 'inward'
            if target_jump == -1:
                jump_dir = 'outward'
        # if verbose:
        print(f"corner: {corner} {corner_name}; jump dir: {target_jump} {jump_dir}")

        # NEW - set orientations to p1=zero and p2=180 (not zero), than add same orientation change to both
        # reset probe ori
        # todo: Exp1_fr_test doesn't use this - it has (0, 0) here and then different values throughout.
        probe1_ori = 0
        probe2_ori = 180
        if corner == 45:
            # in top-right corner, both x and y increase (right and up)
            p1_x = dist_from_fix * 1
            p1_y = dist_from_fix * 1
            #  'orientation' here refers to the relationship between probes,
            #  whereas probe1_ori refers to rotational angle of probe stimulus
            if orientation == 'tangent':
                if target_jump == 1:  # CW
                    probe1_ori += 180
                    probe2_ori += 180
                    probe2_pos = [p1_x + sep - 1, p1_y - sep]
                elif target_jump == -1:  # ACW
                    probe1_ori += 0
                    probe2_ori += 0
                    probe2_pos = [p1_x - sep + 1, p1_y + sep]
            elif orientation == 'radial':
                if target_jump == 1:  # inward
                    probe1_ori += 270
                    probe2_ori += 270
                    # probe2 is left and down from probe1
                    probe2_pos = [p1_x - sep + 1, p1_y - sep]
                elif target_jump == -1:  # outward
                    probe1_ori += 90
                    probe2_ori += 90
                    # probe2 is right and up from probe1
                    probe2_pos = [p1_x + sep - 1, p1_y + sep]
        elif corner == 135:
            p1_x = dist_from_fix * -1
            p1_y = dist_from_fix * 1
            if orientation == 'tangent':
                if target_jump == 1:  # ACW
                    probe1_ori += 90
                    probe2_ori += 90
                    probe2_pos = [p1_x + sep - 1, p1_y + sep]
                elif target_jump == -1:  # CW
                    probe1_ori += 270
                    probe2_ori += 270
                    probe2_pos = [p1_x - sep + 1, p1_y - sep]
            elif orientation == 'radial':
                if target_jump == 1:  # inward
                    probe1_ori += 180
                    probe2_ori += 180
                    # probe2 is right and down from probe1
                    probe2_pos = [p1_x + sep - 1, p1_y - sep]
                elif target_jump == -1:  # outward
                    probe1_ori += 0
                    probe2_ori += 0
                    # probe2 is left and up from probe1
                    probe2_pos = [p1_x - sep + 1, p1_y + sep]
        elif corner == 225:
            p1_x = dist_from_fix * -1
            p1_y = dist_from_fix * -1
            if orientation == 'tangent':
                if target_jump == 1:  # CW
                    probe1_ori += 0
                    probe2_ori += 0
                    probe2_pos = [p1_x - sep + 1, p1_y + sep]
                elif target_jump == -1:  # ACW
                    probe1_ori += 180
                    probe2_ori += 180
                    probe2_pos = [p1_x + sep - 1, p1_y - sep]
            elif orientation == 'radial':
                if target_jump == 1:  # inward
                    probe1_ori += 90
                    probe2_ori += 90
                    # probe2 is right and up from probe1
                    probe2_pos = [p1_x + sep - 1, p1_y + sep]
                elif target_jump == -1:  # outward
                    probe1_ori += 270
                    probe2_ori += 270
                    # probe2 is left and down from probe1
                    probe2_pos = [p1_x - sep + 1, p1_y - sep]
        else:
            corner = 315
            p1_x = dist_from_fix * 1
            p1_y = dist_from_fix * -1
            if orientation == 'tangent':
                if target_jump == 1:  # ACW
                    probe1_ori += 270
                    probe2_ori += 270
                    probe2_pos = [p1_x - sep + 1, p1_y - sep]
                elif target_jump == -1:  # CW
                    probe1_ori += 90
                    probe2_ori += 90
                    probe2_pos = [p1_x + sep - 1, p1_y + sep]
            elif orientation == 'radial':
                if target_jump == 1:  # inward
                    probe1_ori += 0
                    probe2_ori += 0
                    # probe2 is left and up from probe1
                    probe2_pos = [p1_x - sep + 1, p1_y + sep]
                elif target_jump == -1:  # outward
                    probe1_ori += 180
                    probe2_ori += 180
                    # probe2 is right and down from probe1
                    probe2_pos = [p1_x + sep - 1, p1_y - sep]

        # todo: Exp1_fr_test doesn't use this - it adjust .ori and .pos as it goes.
        probe1.ori = probe1_ori
        probe2.ori = probe2_ori
        probe1_pos = [p1_x, p1_y]
        probe1.pos = probe1_pos
        probe2.pos = probe2_pos

        if verbose:
            print(f"probe1: {probe1_pos}, probe2_pos: {probe2_pos}. dff: {dist_from_fix}")

        # VARIABLE FIXATION TIME
        # to reduce anticipatory effects that might arise from fixation always being same length.
        # if False, vary_fix == .5 seconds, so t_fixation is 1 second.
        # if Ture, vary_fix is between 0 and 1 second, so t_fixation is between .5 and 1.5 seconds.
        vary_fix = int(fps / 2)
        if vary_fixation:
            vary_fix = np.random.randint(0, fps)

        # timing in frames for ISI and probe2
        # If probes are presented concurrently, set ISI and probe2 to last for 0 frames.
        isi_dur_fr = ISI
        p2_fr = probe_duration # todo: Exp1_fr_test doesn't use this
        if ISI < 0:
            isi_dur_fr = p2_fr = 0

        # cumulative timing in frames for each part of a trial
        t_fixation = int(fps / 2) + vary_fix
        t_probe_1 = t_fixation + probe_duration
        t_ISI = t_probe_1 + isi_dur_fr
        t_probe_2 = t_ISI + p2_fr
        t_response = t_probe_2 + 10000 * fps  # ~40 seconds to respond

        if verbose:
            print(f"t_fixation: {t_fixation}\n"
                  f"t_probe_1: {t_probe_1}\n"
                  f"t_ISI: {t_ISI}\n"
                  f"t_probe_2: {t_probe_2}\n"
                  f"t_response: {t_response}\n")

        # repeat the trial if [r] has been pressed
        # todo: keep the per-frame stuff to a minimum to reduce the load.

        # take a break every ? trials
        if (trial_number % take_break == 1) & (trial_number > 1):
            continueRoutine = False
            breaks.text = breaks_text + f"\nyou have completed {trial_number}/{total_n_trials} trials."
            breaks.draw()

            # adding this to flush out any logged messages during the breaks.
            # todo: Exp1_fr_test doesn't use this
            # logging.flush()  # write messages out to all targets

            win.flip()

            while not kb.getKeys():
                continueRoutine = True
        else:
            continueRoutine = True

        # todo: add in core.rush(True) for these time critical parts of script.
        # todo: add in global timer and get time from last frame f fixation until first frame of response.
        #  I can later use this to see if there were dropped frames or other time issues across probe1, isi, probe2.

        # loop per frame
        repeat = True
        while repeat:
            frameN = -1

            continueRoutine = True
            while continueRoutine:
                frameN = frameN + 1

                # todo: reset clock once.
                if frameN == t_fixation:
                    # radius is set twice, one here, and once at response time.
                    fixation.setRadius(3)
                    # reset timer to start with probe1 presentation (at last fixation frame).
                    kb.clock.reset()
                    if verbose:
                        print(f"{frameN}: frameN == t_fixation: reset timer")

                    # record time of last fixation frame.
                    # last_fix_fr_time = win.callOnFlip(clock.getTime())

                    win.recordFrameIntervals = True

                # todo: Changed ifs to elifs
                # FIXATION
                if t_fixation >= frameN > 0:
                    # fixation.setRadius(3)
                    # blend_edge_mask.draw()
                    fixation.draw()
                    trials_counter.draw()

                    # if verbose:
                    #     print(f"{frameN}: t_fixation >= frameN > 0: fixation")

                # PROBE 1
                elif t_probe_1 >= frameN > t_fixation:
                    if verbose:
                        print(f"{frameN}: t_probe_1 >= frameN > t_fixation: probe 1")

                    # win.recordFrameIntervals = True
                    # fixation.setRadius(3)
                    # blend_edge_mask.draw()
                    fixation.draw()
                    trials_counter.draw()
                    probe1.draw()
                    if ISI == -1:  # SIMULTANEOUS CONDITION (concurrent)
                        if sep <= 18:  # don't draw 2nd probe in 1probe cond (sep==99)
                            probe2.draw()
                            if verbose:
                                print(f"\t{frameN}: probe2.draw(): conc probes")

                # ISI (only occurs if ISI > 0)
                elif t_ISI >= frameN > t_probe_1:
                    # fixation.setRadius(3)
                    # blend_edge_mask.draw()
                    fixation.draw()
                    trials_counter.draw()
                    if verbose:
                        print(f"{frameN}: t_ISI >= frameN > t_probe_1: ISI")

                # PROBE 2 (Only occurs if ISI > -1, e.g., not concurrent probes)
                elif t_probe_2 >= frameN > t_ISI:
                    if verbose:
                        print(f"{frameN}: t_probe_2 >= frameN > t_ISI: probe 2")

                    # todo: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
                    # todo: shouldn't this be if isi_dur_fr!!!
                    if ISI >= 0:
                        if sep <= 18:  # don't draw 2nd probe in 1probe cond (sep==99)
                            probe2.draw()
                            if verbose:
                                print(f"\t{frameN}: probe2.draw()")
                    # fixation.setRadius(3)
                    # blend_edge_mask.draw()
                    fixation.draw()
                    trials_counter.draw()

                # elif frameN == t_probe_2:
                #     # record time of last probe frame.
                #     last_probe_fr_time = win.callOnFlip(clock.getTime())

                # Response time
                elif frameN > t_probe_2:
                    # print(f"{frameN}: frameN > t_probe_2: response")

                    win.recordFrameIntervals = False
                    total_recorded_fr = len(win.frameIntervals)
                    fr_recorded_list.append(total_recorded_fr)
                    # win.saveFrameIntervals(f"/Users/nickmartin/Library/CloudStorage/OneDrive-CardiffUniversity/PycharmProjects/Cardiff/memory_and_timings/frameIntervals_20221121/run4/FrameIntervals_{trial_number}_ISI{ISI}")

                    # blend_edge_mask.draw()
                    fixation.setRadius(2)
                    fixation.draw()
                    trials_counter.draw()

                    # ANSWER keys
                    # todo: Exp1_fr_test doesn't use this
                    theseKeys = kb.getKeys(keyList=['num_5', 'num_4', 'num_1',
                                                    'num_2', 'w', 'q', 'a', 's'])
                    if len(theseKeys) > 0:  # at least one key was pressed
                        last_key = theseKeys[-1]
                        resp_key = last_key.name
                        resp_rt = last_key.rt
                        if verbose:
                            print(f"theseKeys: {list([i for i in theseKeys])}")
                            print(f"resp_key: {resp_key}")
                            print(f"resp_rt: {resp_rt}")
                            print(f"key.duration: {last_key.duration}")

                        # default assume response incorrect unless meets criteria below
                        resp_corr = 0

                        if corner == 45:
                            if (resp_key == 'w') or (resp_key == 'num_5'):
                                resp_corr = 1
                        elif corner == 135:
                            if (resp_key == 'q') or (resp_key == 'num_4'):
                                resp_corr = 1
                        elif corner == 225:
                            if (resp_key == 'a') or (resp_key == 'num_1'):
                                resp_corr = 1
                        elif corner == 315:
                            if (resp_key == 's') or (resp_key == 'num_2'):
                                resp_corr = 1

                        repeat = False
                        continueRoutine = False

                # regardless of frameN, check for quit
                if kb.getKeys(keyList=["escape"]):
                    thisExp.close()
                    core.quit()

                # redo the trial if I think I made a mistake
                if kb.getKeys(keyList=["r"]) or kb.getKeys(keyList=['num_9']):
                    repeat = True
                    continueRoutine = False
                    continue

                # # todo: sort out how to allow participants to respond, then go back and repeat the frame.
                # #  At the moment it can get stuck in a loop!)
                # if len(win.frameIntervals) > 0:
                #     if max(win.frameIntervals) > max_fr_sec:
                #         print('Repeating this trial as dropped frame.')
                #         print(win.frameIntervals)
                #         win.frameIntervals.clear()
                #         repeat = True
                #         continueRoutine = False
                #         continue

                # gets rid of double presses
                kb.getKeys(clear=True)

                # refresh the screen
                if continueRoutine:
                    win.flip()

        # get duration from last fixation frame to last probe frame
        # probes_fr_dur = last_probe_fr_time - last_fix_fr_time

        # print(win.frameIntervals)
        #
        # # keep this, so it only checks last set of frames.
        # win.frameIntervals.clear()
        # total_recorded_fr = len(win.frameIntervals)
        # total_dropped_fr = win.nDroppedFrames

        # todo: Exp1_fr_test doesn't use this
        # print(f"{total_dropped_fr}/{total_recorded_fr} dropped frames in total")
        # this_trial_recorded_fr = total_recorded_fr - prev_total_recorded_fr
        # this_trial_dropped_fr = total_dropped_fr - prev_total_dropped_fr
        # print(f"{this_trial_dropped_fr}/{this_trial_recorded_fr} dropped frames on this trial")
        # prev_total_recorded_fr = total_recorded_fr
        # prev_total_dropped_fr = total_dropped_fr
        # fr_recorded_list.append(total_recorded_fr)

        # TrialHandler adds info to CSV (but stored in memory until end?)
        thisExp.addData('trial_number', trial_number)
        thisExp.addData('stair', stair_idx)
        thisExp.addData('stair_name', thisStair)
        thisExp.addData('step', step)
        thisExp.addData('separation', sep)
        thisExp.addData('ISI', ISI)
        thisExp.addData('isi_dur_fr', isi_dur_fr)
        thisExp.addData('probe_jump', target_jump)
        thisExp.addData('jump_dir', jump_dir)
        thisExp.addData('probeColor1', probeColor1)
        thisExp.addData('probeColor255', probeColor255)
        thisExp.addData('probeLum', probeLum)
        thisExp.addData('trial_response', resp_corr)
        thisExp.addData('corner', corner)
        thisExp.addData('corner_name', corner_name)
        thisExp.addData('probe_ecc', probe_ecc)
        # thisExp.addData('resp.rt', resp.rt)
        thisExp.addData('resp_key', resp_key)
        thisExp.addData('resp_rt', resp_rt)
        thisExp.addData('orientation', orientation)
        thisExp.addData('vary_fixation', vary_fixation)
        thisExp.addData('t_fixation', t_fixation)
        # thisExp.addData('last_fix_fr_time', last_fix_fr_time)
        # thisExp.addData('last_probe_fr_time', last_probe_fr_time)
        # thisExp.addData('probes_fr_dur', probes_fr_dur)
        thisExp.addData('monitor_name', monitor_name)
        thisExp.addData('selected_fps', fps)
        thisExp.addData('expName', expName)
        thisExp.addData('psychopyVersion', psychopyVersion)
        thisExp.addData('date', expInfo['date'])
        thisExp.addData('time', expInfo['time'])

        # indicates that this trial has finished
        thisExp.nextEntry()

        # updates staircase
        thisStair.newValue(resp_corr)   # so that the staircase adjusts itself

print("end of experiment loop, saving data")

thisExp.dataFileName = filename

# print(f"thisExp: {thisExp.getAllEntries()}")

thisExp.close()

n_dropped_fr = win.nDroppedFrames
print(f"n_dropped_fr: {n_dropped_fr}")

import matplotlib.pyplot as plt
# plt.plot(win.frameIntervals)
# plt.show()
total_recorded_fr = len(win.frameIntervals)
total_dropped_fr = win.nDroppedFrames
print(f"{total_dropped_fr}/{total_recorded_fr} dropped in total (expected: {round(expected_fr_ms, 2)}ms, 'dropped' if > {round(frame_err_ms, 2)})")
plt.plot(win.frameIntervals)
plt.title(f"{monitor_name}, {fps}Hz, {expInfo['date']}\n{total_dropped_fr}/{total_recorded_fr} dropped fr (expected: {round(expected_fr_ms, 2)}ms, 'dropped' if > {round(frame_err_ms, 2)})")
plt.vlines(x=fr_recorded_list, ymin=min(win.frameIntervals), ymax=max(win.frameIntervals), colors='silver', linestyles='dashed')
plt.axhline(y=frame_err_sec, color='red', linestyle='dashed')
# plt.savefig(f"{expInfo['participant']}{os.sep}{expInfo['participant']}_{expInfo['run_number']}{os.sep}{expInfo['participant']}_{expInfo['run_number']}_frames.png")
fig_name = filename = f'{_thisDir}{os.sep}' \
                                         f'{expName}{os.sep}' \
                                         f'{participant_name}{os.sep}' \
                                         f'{participant_name}_{run_number}{os.sep}' \
                                         f'{participant_name}_{run_number}_frames.png'
print(f"fig_name: {fig_name}")
plt.savefig(fig_name)

#
# win.saveFrameIntervals(fileName=None, clear=True)
# the stuff below certainly seems to be what's recommended (close window then core quit)

while not kb.getKeys():
    # display end of experiment screen
    end_of_exp.draw()
    win.flip()
else:
    # logging.flush()  # write messages out to all targets
    thisExp.abort()  # or data files will save again on exit

    # close and quit once a key is pressed
    win.close()
    core.quit()


from typing import  List

#二分查找分两种
def binarySearch(nums: List[int], target: int, leftside=True):
    # 因为本次输出用到了数组所以会出现数组越界的情况

    if len(nums) == 0:
        return -1

    left = 0
    right = len(nums)#用了多一位 过while时直接跳出
    if not leftside:
        while (left < right):
            mid = (left + right) // 2

            if nums[mid] > target:
                right = mid
            elif nums[mid] <= target:
                left = mid+1    #使用mid+1是因//2的特性总是向下偏转
        if left-1>=0 and nums[left-1] == target:
            return left-1
        else:
            return -1
    else:
        while (left < right):
            mid = (left + right) // 2

            if nums[mid] >= target:
                right = mid
            elif nums[mid] < target:
                left = mid + 1
        if right< len(nums) and nums[right] == target:   #因为使用了right所以要格外关注所以说关注一点
            return right
        else:
            return -1

from 二分查找.Bisect import bisect_left,bisect_right
class Solution:
    def searchRange(self, nums: List[int], target: int) -> List[int]:
        return [binarySearch(nums, target), binarySearch(nums, target, False)]

# from 二分查找.Bisect import bisect_right,bisect_left
# #官方对于不存在元素的查找非常马虎 天啊
if __name__ == '__main__':
    print(Solution().searchRange([31,3],3))

二分查找/在排序数组中查找元素的第一个和最后一个位置34.py

在排序数组中查找元素的第一个和最后一个位置34.py


from GPy.models import GPRegression
from GPy.kern.src.sde_matern import sde_Matern32 as Matern32
from GPy.kern.src.sde_matern import sde_Matern52 as Matern52
from GPy.kern.src.rbf import RBF
from emukit.model_wrappers import GPyModelWrapper
from emukit.experimental_design.model_free.latin_design import LatinDesign
from emukit.bayesian_optimization.loops import BayesianOptimizationLoop
from emukit.core.loop import UserFunctionWrapper
import numpy as np
from l2_bayes_opt.acquisitions import (
    L2NegativeLowerConfidenceBound as L2_LCB,
    L2ExpectedImprovement as L2_EI)
import matlab.engine
from Engine import LiX_wrapper

###
salt = 'LiI'
structure = 'Rocksalt'
INIT_POINTS=30
BO_ITER = 30
NOISE = 1E-4
frac_M = True
frac_X = True
mix_rules = True
bo_flag = False
focus = None # None, 'energy ', or 'constant'
###
if not mix_rules:
    n_params = 6
else:
    n_params = 4

eng = matlab.engine.start_matlab()

if NOISE == 0:
    NOISE = 1E-6 # numerical reason

def save(bayes_state):
    X = bayes_state.loop_state.X
    Y = bayes_state.loop_state.Y

    return X,Y

def black_box_func(x): #!!!
    global counter, Y_original
    x = x.flatten()

    if max(np.shape(x)) == 4:
        s_M = x[0]
        s_X = x[1]
        e_M = x[2]
        e_X = x[3]

        params = np.array([[s_M,s_X],[e_M,e_X]])

    else:
        s_M = x[0]
        s_X = x[1]
        s_MX = x[2]
        e_M = x[3]
        e_X = x[4]
        e_MX = x[5]

        params = np.array([[s_M,s_X,s_MX],[e_M,e_X,e_MX]])

    #First True is the bo_mode data type
    # LiX_wrapper(bo_mode,salt,structure,model,model_params,par,verbose,eng)
    opt_result = LiX_wrapper(True,salt,structure,'JC',params,
                             False,False,eng)

    counter += 1
    Y_original = np.append(Y_original,opt_result)

    if np.isnan(opt_result[0][0]):
        opt_result = np.zeros((1,10))

    print('Iteration {}-->params:\n{}'.format(counter,
          np.array2string(params,precision=3)))
    print('Energy compared to target:{:.2f}'.format(opt_result[0][0]-target[0][0]))
    print('Lattice parameters compared to target:{:.2f}'.format(
                opt_result[0][1]-target[0][1]))

    if frac_X == False:
        opt_result = opt_result[:,:7]
    if frac_M == False:
        opt_result = opt_result[:,:4]

    if bo_flag:
        if focus == 'energy':
            return opt_result.flatten()[0].reshape(1,-1)
        if focus == 'constant':
            return opt_result.flatten()[1:4].reshape(1,-1)
        else:
            return opt_result[:,:].reshape(1,-1)
    else:
        return opt_result.reshape(1,-1)

if n_params == 4:
    from params_setting import parameter_space_4d as parameter_space
else:
    from params_setting import parameter_space_6d as parameter_space

f=UserFunctionWrapper(black_box_func)

def main():
    print("######################")
    global target,X0,Y0,values,frac_M,frac_X,bo_flag

    #target_params = np.array([[0.14,0.4],[1.4,0.03]])

    #target = LiX_wrapper(True,'LiF','Rocksalt','JC',
    #                     target_params,False,False,eng)

    target = np.array([[-764.5,6.012*0.99,6.012*0.99,6.012*0.99]])

    if focus == 'energy':
        target_comp = target[0,0].reshape(1,-1)
    if focus == 'constant':
        target_comp = target[0,1].reshape(1,-1)
    else:
        target_comp = target[0,:4].reshape(1,-1)

    print('Target initialized!')

    latin_design = LatinDesign(parameter_space=parameter_space)
    X0 = latin_design.get_samples(INIT_POINTS)
    Y0 = np.array([])
    for x in X0:
        x = np.array([x])
        Y0 = np.append(Y0,f.evaluate(x))
    values = []

    for y in Y0:
        values.append(y.Y)

    values = np.asarray(values,dtype=float)

    ### Redundancy check
    if (values[:,7:-1]==values[0,7]).all():
        values = values[:,:7]
        frac_X = False

    if (values[:,4:7]==values[0,4]).all():
        values = values[:,:4]
        frac_M = False

    values = values.reshape(-1,np.max(np.shape(target)))
    bo_flag = True

    if focus == 'energy':
        values = values[:,0].reshape(-1,1)
    if focus == 'constant':
        values = values[:,1:4].reshape(-1,3)

    ### BO Loop
    kern = Matern52(X0.shape[1],variance=1)
    model = GPRegression(X0, values, kernel=kern,
                         normalizer=True, noise_var=NOISE) # Kernel = None: RBF default

    model.optimize(optimizer='lbfgsb')
    model.optimize_restarts(num_restarts=50,verbose=False)
    model_wrapped = GPyModelWrapper(model)

    acq = L2_LCB(model=model_wrapped, target=target_comp, beta = np.float64(1.))
    # beta is the exploration constant
    bayesopt_loop = BayesianOptimizationLoop(
                model=model_wrapped, space=parameter_space, acquisition=acq)
    bayesopt_loop.run_loop(f, BO_ITER)

    return save(bayesopt_loop)

if __name__ == '__main__':
    counter = 0
    Y_original = np.array([])
    test = main()
    Y_original = Y_original.reshape(INIT_POINTS+BO_ITER,-1)

    tmp = (Y_original,test[0],test[1],'LiI','Rocksalt','with_mix','both','30+30')
    import pickle
    with open('./tmp_data/LiI/opt_both_MIX.pickle', 'wb') as f:
        pickle.dump(tmp, f)
    eng.quit()


import os,argparse
import numpy as np
from PIL import Image
from models import *
import torch
import torch.nn as nn
import torchvision.transforms as tfs
import torchvision.utils as vutils
import matplotlib.pyplot as plt
from torchvision.utils import make_grid
import torch.npu
import os
NPU_CALCULATE_DEVICE = 0
if os.getenv('NPU_CALCULATE_DEVICE') and str.isdigit(os.getenv('NPU_CALCULATE_DEVICE')):
	NPU_CALCULATE_DEVICE = int(os.getenv('NPU_CALCULATE_DEVICE'))
if torch.npu.current_device() != NPU_CALCULATE_DEVICE:
	torch.npu.set_device(f'npu:{NPU_CALCULATE_DEVICE}')

abs=os.getcwd()+'/'
def tensorShow(tensors,titles=['haze']):
        fig=plt.figure()
        for tensor,tit,i in zip(tensors,titles,range(len(tensors))):
            img = make_grid(tensor)
            npimg = img.numpy()
            ax = fig.add_subplot(221+i)
            ax.imshow(np.transpose(npimg, (1, 2, 0)))
            ax.set_title(tit)
        plt.show()

parser=argparse.ArgumentParser()
parser.add_argument('--task',type=str,default='its',help='its or ots')
parser.add_argument('--test_imgs',type=str,default='test_imgs',help='Test imgs folder')
opt=parser.parse_args()
dataset=opt.task
gps=3
blocks=19
img_dir=abs+opt.test_imgs+'/'
output_dir=abs+f'pred_FFA_{dataset}/'
print("pred_dir:",output_dir)
if not os.path.exists(output_dir):
    os.mkdir(output_dir)
model_dir=abs+f'trained_models/{dataset}_train_ffa_{gps}_{blocks}.pk'
device='npu' if torch.npu.is_available() else 'cpu'
ckp=torch.load(model_dir,map_location=device)
net=FFA(gps=gps,blocks=blocks)
net=net
net.load_state_dict(ckp['model'])
net.eval()
for im in os.listdir(img_dir):
    print(f'\r {im}',end='',flush=True)
    haze = Image.open(img_dir+im)
    haze1= tfs.Compose([
        tfs.ToTensor(),
        tfs.Normalize(mean=[0.64, 0.6, 0.58],std=[0.14,0.15, 0.152])
    ])(haze)[None,::]
    haze_no=tfs.ToTensor()(haze)[None,::]
    with torch.no_grad():
        pred = net(haze1)
    ts=torch.squeeze(pred.clamp(0,1).cpu())
    tensorShow([haze_no,pred.clamp(0,1).cpu()],['haze','pred'])
    vutils.save_image(ts,output_dir+im.split('.')[0]+'_FFA.png')


from printer import Printer
from text    import *
from thread  import BaseThread as Thread

import dateutil.parser
from   dateutil.tz import tzlocal

import gps, time

class GPSThread(Thread):
    def __init__(self,logger):
        super(GPSThread,self).__init__(logger)

    def run(self):
        session = gps.gps(mode=gps.WATCH_ENABLE)
        while self.iterate():
            gpsinfo = None
            satellites = None
            maxtries = 10
            try:
                while not gpsinfo and maxtries:
                    report = session.next()
                    if report['class'] == 'TPV':
                        gpsinfo = dict(report)
                        gpsinfo['satellites'] = satellites
                        break
                    elif report['class'] == 'SKY':
                        sats_used  = len([ s for s in report.get('satellites', {}) if s['used'] == True])
                        sats_avail = len([ s for s in report.get('satellites', {})])
                        satellites = (sats_used, sats_avail)
                    maxtries -= 1
            except StopIteration:
                pass
            if gpsinfo:
                self.set_data(gpsinfo)
            if self.sleep(1):
                break
        session.close()

class GPSPrinter(Printer):
    def __init__(self,**kwargs):
        self.PAGES = ['POS', 'SPEED', 'TRACK', 'TIME' ]
        super(GPSPrinter,self).__init__(**kwargs)

        # always show GPS printer
        self.timeout_override = True

        # internal variables
        self._thread = None

    def init_layout(self):
        super(GPSPrinter,self).init_layout()

        page = Page(self.lcd,idx=self.PAGE.POS)
        self.lon = page.add_scroll_line(header="Lon")
        self.lat = page.add_scroll_line(header="Lat")
        self.pages.append(page)

        page = Page(self.lcd,idx=self.PAGE.SPEED)
        self.speed = page.add_scroll_line(header="Speed")
        self.alt   = page.add_scroll_line(header="Alt")
        self.pages.append(page)

        page = Page(self.lcd,idx=self.PAGE.TRACK)
        self.track = page.add_scroll_line(header="Track")
        self.sat   = page.add_scroll_line(header="Sats used")
        self.pages.append(page)

        page = Page(self.lcd,idx=self.PAGE.TIME)
        self.time = page.add_scroll_line(header="Time")
        self.date = page.add_scroll_line(header="Date")
        self.pages.append(page)

        self.set_active(self.PAGE.POS)

    def __del__(self):
        self.stop()

    def stop(self):
        if not self._thread:
            return
        self._thread.stop()

    def init(self):
        if not self._thread:
            self._thread = GPSThread(self.logger)
            self._thread.start()
        self.update()
        self.lcd.set_color(*self.color)
        self.render(True)

    def render(self,force=False):
        self.update()
        super(GPSPrinter,self).render(force)

    def update(self):
        gpsinfo = self._thread.pop_data()
        if not gpsinfo:
            return
        self.logger.debug( '%s updating' % type(self).__name__)
        mode = gpsinfo.get('mode', 1)
        if mode < 2:
            return
        gpstime = gpsinfo.get('time', None)
        self.lon.setText(  ("%f"        %  gpsinfo.get('lon', 0.0)     ).rjust(self.lon.width))
        self.lat.setText(  ("%f"        %  gpsinfo.get('lat', 0.0)     ).rjust(self.lat.width))
        self.speed.setText(("%.1f km/h" % (gpsinfo.get('speed', 0)*3.6)).rjust(self.speed.width))
        self.track.setText(("%d deg"    %  gpsinfo.get('track', 0)     ).rjust(self.track.width))
        self.alt.setText(  ("%d m"      %  gpsinfo.get('alt',   0)     ).rjust(self.alt.width))
        satellites = gpsinfo['satellites']
        if satellites:
            self.sat.setText(("%d/%d" %  satellites).rjust(self.sat.width))
        if gpstime:
            localtime = dateutil.parser.parse(gpstime).astimezone(tzlocal())
            self.time.setText(("%s" % localtime.strftime('%H:%M:%S')).rjust(self.time.width))
            self.date.setText(("%s" % localtime.date()).rjust(self.date.width))


"""
A very flexible query constructor (albeit confusing)
that allows you to describe the structure of related tables in a few lines.
For more information, see the application documentation.
"""
from config.models import ExchangeTableSettings, SQLDBSettings

class QueryBuildMixin:
    """The query construction functionality for the PostgresSQL Extractor placed in a separate class."""

    # The name of the SQL service subquery used to sort and filter newly modified data
    TRACKED_FIELD_NAME = "_tracked_field"
    # The name of the service field from the SQL subquery used to sort and filter newly modified data
    TRACKED_TABLE_NAME = "_tracked_table"
    # The crutch is used to get rid of filtering for the first requests.
    WHERE_COMMENT = "IS NOT NULL /*CHANGE*/"

    def __init__(
        self, source: ExchangeTableSettings, db_settings: SQLDBSettings | None = None
    ):
        self.source = source
        self.db_schema = "" if db_settings is None else db_settings.db_schema
        self.query_limit = (
            None if db_settings is None else db_settings.query_entries_limit
        )
        self.default_key_field = (
            "id" if db_settings is None else db_settings.key_field_name
        )

    @staticmethod
    def get_table_alias(table: ExchangeTableSettings) -> str:
        return format(table.name if not table.alias else table.alias)

    @staticmethod
    def get_full_field_name(table_alias: str, field: str, quotes: bool = True) -> str:
        return (
            '"{0}"."{1}"'.format(table_alias, field)
            if quotes
            else "{0}.{1}".format(table_alias, field)
        )

    def get_field_alias(self, table: ExchangeTableSettings, field: str):
        table_alias = self.get_table_alias(table)
        return table.aliases.get(field, "{0}__{1}".format(table_alias, field))

    def get_full_table_name(self, table: ExchangeTableSettings) -> str:
        db_schema = table.db_schema if table.db_schema else self.db_schema
        table_name = (
            table.name if not db_schema else '"{}"."{}"'.format(db_schema, table.name)
        )
        return '{} AS "{}"'.format(table_name, self.get_table_alias(table))

    def get_table_with_joins(
        self, table: ExchangeTableSettings, parent_table: ExchangeTableSettings
    ):
        table_alias = self.get_table_alias(table)
        joins = None
        if parent_table is not None and len(table.join) > 0:
            parent_table_alias = self.get_table_alias(parent_table)
            joins = [
                "{} = {}".format(
                    self.get_full_field_name(parent_table_alias, value),
                    self.get_full_field_name(table_alias, key),
                )
                for key, value in table.join.items()
            ]
        return self.get_full_table_name(table), joins

    def get_table_key_field_name(self, table: ExchangeTableSettings):
        return table.key_field_name or self.default_key_field

    def _get_fields_and_tables_parts_sql(
        self,
        current_table: ExchangeTableSettings,
        parent_table: ExchangeTableSettings | None = None,
        depth=0,
    ) -> dict[str, list | tuple]:
        """
        The method returns a data structure containing fields and tables.
        Based on this structure, an SQL query will be built.
        """
        result = {
            "fields": [],  # [(field, field_full_name, field_alias)]
            "tables": [],  # [(table_with_alias, join_on)]
        }
        table_alias = self.get_table_alias(current_table)

        # Adding fields to result
        for field in current_table.fields:
            field_alias = self.get_field_alias(current_table, field)
            if parent_table is None:
                field_alias = current_table.aliases.get(field, field)
            field_full_name = self.get_full_field_name(table_alias, field)
            result["fields"].append((field, field_full_name, field_alias))

        # Adding current table to result
        result["tables"].append(self.get_table_with_joins(current_table, parent_table))

        # Max depth for children table = 2
        if current_table.children is not None and depth < 2:
            for children_table in current_table.children:
                children_result = self._get_fields_and_tables_parts_sql(
                    children_table, current_table, depth + 1
                )
                for key, value in children_result.items():
                    result[key] += value

        # Grouping fields
        if depth == 1 and current_table.group is not None:
            if len(result["fields"]) == 1:
                field = result["fields"][0]
                agg = "array_agg (DISTINCT {})".format(field[1])
                result["fields"][0] = (None, agg, current_table.group)
            elif len(result["fields"]) > 1:
                fields = ", \n".join(
                    [
                        "  '{}', {}".format(
                            field[0] if "__" in field[2] else field[2], field[1]
                        )
                        for field in result["fields"]
                    ]
                )
                agg = (
                    "COALESCE (json_agg(DISTINCT jsonb_build_object(\n{}\n))"
                    " FILTER (WHERE {} is not null), '[]')".format(
                        fields, result["fields"][-1][1]
                    )
                )
                result["fields"] = [(None, agg, current_table.group)]
        return result

    def _get_tracked_fields_with_related_tables(
        self,
        current_table: ExchangeTableSettings,
        parent_tables: list[ExchangeTableSettings] | None = None,
        depth=0,
        compare_field_actual_for_child_queries: bool | None = None,
    ):
        """
        A recursive query that forms a list of fields, tables and relationships between
        them for further formation of an SQL query
        """
        result = {}  # tracked_field: [(table_with_alias, join_on), ...]
        if parent_tables is None:
            parent_tables = [current_table]
        else:
            parent_tables.append(current_table)
        if current_table.field_actual_state_name:
            field_full_name = self.get_full_field_name(
                self.get_table_alias(current_table),
                current_table.field_actual_state_name,
                False,
            )

            first_table = parent_tables[0]
            key_field = self.get_table_key_field_name(first_table)
            key_field_full_name = self.get_full_field_name(
                self.get_table_alias(first_table), key_field
            )
            query_str_list = [
                'JOIN (\n  SELECT {0} AS "id", MAX({1}) AS "{2}"'.format(
                    key_field_full_name, field_full_name, self.TRACKED_FIELD_NAME
                ),
            ]
            parent_table = None
            for table in parent_tables:
                table_str = "  FROM" if parent_table is None else "  JOIN"
                table_join = self.get_table_with_joins(table, parent_table)
                if table_join[1] is not None:
                    query_str_list.append(
                        "{0} {1} ON {2}".format(
                            table_str, table_join[0], ", ".join(table_join[1])
                        )
                    )
                else:
                    query_str_list.append("{0} {1}".format(table_str, table_join[0]))
                parent_table = table

            # A block that adds filtering of records in the child table if necessary.
            root_table = parent_tables[0]
            where_start = ""
            if (
                compare_field_actual_for_child_queries is True
                and current_table.compare_field_actual_with_parent_query is not False
                and root_table.field_actual_state_name
            ):
                root_field = self.get_full_field_name(
                    self.get_table_alias(root_table), root_table.field_actual_state_name
                )
                where_start = "{} < {} AND".format(root_field, field_full_name)

            query_str_list.append(
                "  WHERE {0} {1} {2}\n  GROUP BY {3}\n  ORDER BY {4}".format(
                    where_start,
                    field_full_name,
                    self.WHERE_COMMENT,
                    key_field_full_name,
                    self.TRACKED_FIELD_NAME,
                )
            )
            if self.query_limit is not None:
                query_str_list.append("  LIMIT {} OFFSET %s".format(self.query_limit))
            query_str_list.append(
                '  ) AS "{0}" ON {1} = "{0}"."id"'.format(
                    self.TRACKED_TABLE_NAME, key_field_full_name
                )
            )

            result[field_full_name] = "\n".join(query_str_list)

        if current_table.compare_field_actual_for_child_queries is not None:
            compare_field_actual_for_child_queries = (
                current_table.compare_field_actual_for_child_queries
            )
        if current_table.children is not None and depth < 2:
            for children_table in current_table.children:
                child_result = self._get_tracked_fields_with_related_tables(
                    children_table,
                    parent_tables,
                    depth + 1,
                    compare_field_actual_for_child_queries,
                )
                result.update(child_result)

        parent_tables.pop()
        return result

    def get_tracked_fields_with_query(self):
        return self._get_tracked_fields_with_related_tables(self.source.table)

    def select_query_for_load(
        self, where_filter: str = "", adding_fields: [str] = [], adding_join: [str] = []
    ) -> str:
        """
        Returns an SQL query based on the structure described in self.source.table.
        Request example:
            SELECT
                "fw"."id" AS "id",
                "fw"."title" AS "title",
                "fw"."description" AS "description",
                "fw"."rating" AS "imdb_rating",
                "fw"."modified" AS "modified",
                array_agg (DISTINCT "gr"."name") AS "genre",
                COALESCE (json_agg(DISTINCT jsonb_build_object(
                    'role', "pfw"."role",
                    'id', "pn"."id",
                    'name', "pn"."full_name",
                    'modified', "pn"."modified"
                )) FILTER (WHERE "pn"."modified" is not null), '[]') AS "persons",
                "_tracked_table"."_tracked_field"
            FROM "content"."film_work" AS "fw"
            LEFT JOIN "content"."genre_film_work" AS "gfw" ON ("fw"."id" = "gfw"."film_work_id")
            LEFT JOIN "content"."genre" AS "gr" ON ("gfw"."genre_id" = "gr"."id")
            LEFT JOIN "content"."person_film_work" AS "pfw" ON ("fw"."id" = "pfw"."film_work_id")
            LEFT JOIN "content"."person" AS "pn" ON ("pfw"."person_id" = "pn"."id")
            JOIN (
                SELECT "fw"."id" AS "id", MAX(pn.modified) AS "_tracked_field"
                FROM "content"."film_work" AS "fw"
                JOIN "content"."person_film_work" AS "pfw" ON "fw"."id" = "pfw"."film_work_id"
                JOIN "content"."person" AS "pn" ON "pfw"."person_id" = "pn"."id"
                WHERE "fw"."modified" < pn.modified AND pn.modified > %s
                GROUP BY "fw"."id"
                ORDER BY _tracked_field
                LIMIT 10000 OFFSET %s
            ) AS "_tracked_table" ON "fw"."id" = "_tracked_table"."id"
            GROUP BY
            "fw"."id",
            "fw"."title",
            "fw"."description",
            "fw"."rating",
            "fw"."modified",
            "_tracked_table"."_tracked_field"
            LIMIT 10000
        """
        fields_and_tables = self._get_fields_and_tables_parts_sql(self.source.table)
        tables = []
        for table in fields_and_tables["tables"]:
            tables.append(
                table[0]
                if table[1] is None
                else "LEFT JOIN {} ON ({})".format(table[0], " AND ".join(table[1]))
            )
        tables.extend(adding_join)
        fields = []
        group_by = []
        group_by_need = False
        for field in fields_and_tables["fields"]:
            if field[0] is not None:
                group_by.append(field[1])
            else:
                group_by_need = True
            fields.append('{} AS "{}"'.format(field[1], field[2]))
        fields.extend(adding_fields)
        group_by.extend(adding_fields)
        fields_str = ",\n ".join(fields)
        tables_str = "\n".join(tables)
        group_by_str = ""
        if group_by_need:
            group_by_str = "GROUP BY\n {}".format(",\n ".join(group_by))
        where_str = "" if where_filter == "" else "\nWHERE {}".format(where_filter)
        sql_text = "SELECT \n {0} \nFROM {1} {2}\n{3}\n".format(
            fields_str, tables_str, where_str, group_by_str
        )

        if self.query_limit is not None:
            sql_text += "LIMIT {}".format(self.query_limit)

        return sql_text


import xml.etree.ElementTree as ET
from urllib.parse import quote
from pathlib import Path
tree = ET.parse('database.xml')
root = tree.getroot()

def vdj_to_dict(root: ET.Element):
    tracks = {}
    for i, song in enumerate(root):
        # if "Aarena" not in song.attrib["FilePath"]:
        #     continue
        # print(child.find("Tags").attrib)
        path = song.attrib["FilePath"]
        relpath = path.split("\\")[-1]
        filename = Path(relpath).stem
        file_kind = Path(relpath).suffix.split('.')[-1]
        file_size = song.attrib["FileSize"]

        tags = song.find("Tags")
        infos = song.find("Infos")
        scan = song.find("Scan")

        try:
            title = tags.attrib["Title"]
        except KeyError:
            title = filename

        try:
            artist = tags.attrib["Author"]
        except KeyError:
            artist = None

        try:
            album = tags.attrib["Album"]
        except KeyError:
            album = None

        try:
            year = tags.attrib["Year"]
        except KeyError:
            year = None

        length = str(round(float(infos.attrib["SongLength"]), 3))
        bitrate = infos.attrib["Bitrate"]
        playcount = infos.attrib["PlayCount"]

        tonality = scan.attrib["Key"]
        bpm = str(round(1 / float(scan.attrib["Bpm"]) * 60, 3))

        cues = []

        for poi in song.findall("Poi"):
            if poi.attrib["Type"] == "cue":
                try:
                    pos = str(round(float(poi.attrib["Pos"]), 3))
                    num = str(int(poi.attrib["Num"]) - 1)
                    cues.append({"pos": pos, "num": num})
                except KeyError:
                    pass

            elif poi.attrib["Type"] == "beatgrid":
                grid_start = str(round(float(poi.attrib["Pos"]), 6))

        # print(title)

        # print(path)
        # print(filename)
        # print(file_kind)
        # print(title)

        # print([path, relpath, filename, file_kind, title, length, bitrate, playcount, tonality, bpm])
        # print(cues)
        tracks[i] = {
            "file_info": {"path": path, "relpath": relpath, "filename": filename, "file_kind": file_kind, "file_size": file_size},
            "track_info": {"title": title, "artist": artist, "album": album, "year": year, "length": length, "bitrate": bitrate, "playcount": playcount, "bpm": bpm, "tonality": tonality},
            "cue_info": {"grid_start": grid_start, "cues": cues}
        }

    return tracks

def dict_to_rkbx(set_dict: dict, output_path: str):
    root = ET.Element("DJ_PLAYLISTS")
    root.attrib["Version"] = "1.0.0"

    product = ET.SubElement(root, "PRODUCT")
    product.attrib["Name"] = "groffrey converter"
    product.attrib["Version"] = "0.0.1"
    product.attrib["Company"] = "grof productions"

    track_count = len(set_dict)

    collection = ET.SubElement(root, "COLLECTION")
    collection.attrib["Entries"] = str(track_count)

    for track_id, track_dict in set_dict.items():
        track = ET.SubElement(collection, "TRACK")
        track.attrib["TrackID"] = str(track_id)
        track.attrib["Name"] = track_dict["track_info"]["title"]
        if track_dict["track_info"]["artist"]:
            track.attrib["Artist"] = track_dict["track_info"]["artist"]
        if track_dict["track_info"]["album"]:
            track.attrib["Album"] = track_dict["track_info"]["album"]
        track.attrib["Kind"] = track_dict["file_info"]["file_kind"]

        raw_path = track_dict["file_info"]["path"]
        safe_path = quote(raw_path)
        rkbx_path = f"file://localhost/{safe_path}"
        track.attrib["Location"] = rkbx_path
        track.attrib["Size"] = track_dict["file_info"]["file_size"]
        track.attrib["TotalTime"] = track_dict["track_info"]["length"]
        if track_dict["track_info"]["year"]:
            track.attrib["Year"] = track_dict["track_info"]["year"]
        track.attrib["Tonality"] = track_dict["track_info"]["tonality"]

        for cue_data in track_dict["cue_info"]["cues"]:
            cue = ET.SubElement(track, "POSITION_MARK")
            cue.attrib["Name"] = "cue"
            cue.attrib["Type"] = "0"
            cue.attrib["Start"] = cue_data["pos"]
            cue.attrib["Num"] = cue_data["num"]

            cue.attrib["Red"] = "40"
            cue.attrib["Green"] = "226"
            cue.attrib["Blue"] = "20"

        tempo = ET.SubElement(track, "TEMPO")
        tempo.attrib["Inizio"] = track_dict["cue_info"]["grid_start"]
        tempo.attrib["Bpm"] = track_dict["track_info"]["bpm"]

    playlists = ET.SubElement(root, "PLAYLISTS")

    node_root = ET.SubElement(playlists, "NODE")
    node_root.attrib["Type"] = "0"
    node_root.attrib["Name"] = "ROOT"
    node_root.attrib["Count"] = "1"

    node_playlist = ET.SubElement(node_root, "NODE")
    node_playlist.attrib["Name"] = input("Enter playlist name: ")
    node_playlist.attrib["Type"] = "1"
    node_playlist.attrib["KeyType"] = "0"
    node_playlist.attrib["Entries"] = str(track_count)

    for i in tracks.keys():
        track = ET.SubElement(node_playlist, "TRACK")
        track.attrib["Key"] = str(i)

    tree = ET.ElementTree(root)
    ET.indent(tree, space=" ", level=0)
    tree.write(output_path, "UTF-8")

tracks = vdj_to_dict(root)

dict_to_rkbx(tracks, "testout.xml")


from django.shortcuts import render
import string
# Create your views here.
from .models import Lugat

def tarjima(request):
    if request.method == 'GET':
        soz = request.GET.get('q', '')
        if soz and soz != '':
            natija = Lugat.objects.filter(inglizcha__contains=soz).all()[:5]
        else:
            natija = None

    return render(request, 'tarjimon.html', {'q':soz, 'natija': natija})


from __future__ import annotations

from collections import defaultdict, deque
from copy import deepcopy
import importlib
import logging
from pathlib import Path
import random
from typing import Self

from ordered_set import OrderedSet

from ph_rando.common import ShufflerAuxData
from ph_rando.settings import ShufflerHook
from ph_rando.shuffler._parser import (
    Edge,
    Node,
    annotate_logic,
    connect_mail_nodes,
    connect_rooms,
    connect_shop_nodes,
    parse_aux_data,
)
from ph_rando.shuffler.aux_models import Check, Item

logger = logging.getLogger(__name__)

DUNGEON_REWARD_CHECKS: dict[str, str] = {
    'BlaazBossRoom.Main.SpiritOfPower': 'PowerSpirit',
    'CyclokBossRoom.Main.SpiritOfWisdom': 'WisdomSpirit',
    'CraykBossRoom.Main.SpiritOfCourage': 'CourageSpirit',
    'GoronTemple.CrimsonineRoom.Crimsonine': 'Crimsonine',
    'IceTemple.AzurineRoom.Azurine': 'Azurine',
    'MutohTemple.B4.Aquanine': 'Aquanine',
}

IMPORTANT_ITEMS: set[str] = {
    # Progression items
    'Bombchus',
    'Bombs',
    'Boomerang',
    'Bow',
    'Cannon',
    'CycloneSlate',
    'FishingRod',
    'GrapplingHook',
    'Hammer',
    'KingKey',
    'GhostKey',
    'ProgressiveSword',
    'PhantomHourglass',
    'RegalNecklace',
    'SalvageArm',
    'Shovel',
    'SunKey',
    'Shield',
    'JolenesLetter',
    # Sea charts
    'NESeaChart',
    'NWSeaChart',
    'SESeaChart',
    'SWSeaChart',
    # Gems
    'PowerGem',
    'WisdomGem',
    'CourageGem',
    # Trading quest items
    'Kaleidoscope',
    'WoodHeart',
    'GuardNotebook',
    'HeroNewClothes',
    # Treasure maps
    'TreasureMapSW1',
    'TreasureMapSW2',
    'TreasureMapSW3',
    'TreasureMapSW4',
    'TreasureMapSW5',
    'TreasureMapSW6',
    'TreasureMapSW7',
    'TreasureMapNW1',
    'TreasureMapNW2',
    'TreasureMapNW3',
    'TreasureMapNW4',
    'TreasureMapNW5',
    'TreasureMapNW6',
    'TreasureMapNW7',
    'TreasureMapNW8',
    'TreasureMapSE1',
    'TreasureMapSE2',
    'TreasureMapSE3',
    'TreasureMapSE4',
    'TreasureMapSE5',
    'TreasureMapSE6',
    'TreasureMapSE7',
    'TreasureMapSE8',
    'TreasureMapNE1',
    'TreasureMapNE2',
    'TreasureMapNE3',
    'TreasureMapNE4',
    'TreasureMapNE5',
    'TreasureMapNE6',
    'TreasureMapNE7',
    'TreasureMapNE8',
}

class AssumedFillFailed(Exception):
    pass

class Shuffler:
    settings: dict[str, str | list[str] | bool]
    aux_data: ShufflerAuxData
    starting_node: Node

    _checks_to_exclude: set[Check]

    def __init__(
        self: Self,
        seed: str,
        settings: dict[str, str | list[str] | bool],
        starting_node_name: str = 'Mercay.OutsideOshus.Outside',
        areas_directory: Path | None = None,
        enemy_mapping_file: Path | None = None,
        macros_file: Path | None = None,
    ) -> None:
        random.seed(seed)

        self.settings = settings

        self.aux_data = parse_aux_data(
            areas_directory=areas_directory,
            enemy_mapping_file=enemy_mapping_file,
            macros_file=macros_file,
        )
        self.aux_data.seed = seed

        self._checks_to_exclude: set[Check] = set()

        self._annotate_logic(logic_directory=areas_directory)
        self._connect_rooms()
        self._connect_mail_nodes()
        self._connect_shop_nodes()
        self._apply_settings()
        self._remove_unsupported_items()

        self.starting_node = [
            node
            for area in self.aux_data.areas
            for room in area.rooms
            for node in room.nodes
            if node.name == starting_node_name
        ][0]

    def _annotate_logic(self: Self, logic_directory: Path | None = None) -> None:
        return annotate_logic(areas=self.aux_data.areas, logic_directory=logic_directory)

    def _connect_rooms(self: Self) -> None:
        return connect_rooms(self.aux_data.areas)

    def _connect_mail_nodes(self: Self) -> None:
        return connect_mail_nodes(self.aux_data.areas)

    def _connect_shop_nodes(self: Self) -> None:
        return connect_shop_nodes(self.aux_data.areas)

    def _apply_settings(self) -> None:
        from ph_rando.common import RANDOMIZER_SETTINGS

        for setting_name, setting_value in self.settings.items():
            hook = RANDOMIZER_SETTINGS[setting_name].shuffler_hook
            if hook:
                fn: ShufflerHook = getattr(
                    importlib.import_module('ph_rando.shuffler._settings'), hook
                )
                logger.debug(f'Setting "{setting_name}"...')
                fn(value=setting_value, shuffler=self)

    def _remove_unsupported_items(self: Self) -> None:
        """Removes any items that cannot currently be patched from shuffle pool."""
        # TODO: remove this function when all item types are supported.
        from ph_rando.shuffler.aux_models import (
            BossReward,
            Freestanding,
            Mail,
            MinigameRewardChest,
            OnEnemy,
            SpiritUpgrade,
        )

        unsupported_types = (
            Freestanding,
            OnEnemy,
            MinigameRewardChest,
            Mail,
            BossReward,
            SpiritUpgrade,
        )
        for area in self.aux_data.areas:
            for room in area.rooms:
                for check in room.chests:
                    if check not in self._checks_to_exclude and type(check) in unsupported_types:
                        logger.warning(
                            f'Excluding "{".".join([area.name, room.name, check.name])}" from '
                            f'shuffled item pool (item type "{type(check).__name__}" not '
                            'supported yet)'
                        )
                        self.exclude_check(check)

    def generate(self: Self) -> ShufflerAuxData:
        # Copy all items to a list and set all checks to null
        item_pool: list[Item] = []
        for area in self.aux_data.areas:
            for room in area.rooms:
                for check in room.chests:
                    item = check.contents
                    # print(check)
                    # Append area name for keys, so that we know where we can place
                    # it later on.
                    if item.name == 'SmallKey':
                        item.name += f'_{area.name}'

                    if check not in self._checks_to_exclude:
                        item_pool.append(item)
                        check.contents = None  # type: ignore

        while True:
            # Save shallow copy of original list so we can restart if the assumed fill fails
            backup_item_pool = item_pool.copy()

            random.shuffle(item_pool)

            try:
                self._place_dungeon_rewards(item_pool)
                self._place_boss_keys(item_pool)
                self._place_small_keys(item_pool)
                self._place_important_items(item_pool)
                self._place_rest_of_items(item_pool)
                break
            except AssumedFillFailed:
                logging.info('Assumed fill failed! Trying again...\n')

                # Remove all items that were placed, and add them back to the item pool
                for area in self.aux_data.areas:
                    for room in area.rooms:
                        for check in room.chests:
                            if check.contents is not None and check not in self._checks_to_exclude:
                                check.contents = None  # type: ignore
                item_pool = backup_item_pool
                continue

        return self.aux_data

    def _place_item(
        self: Self,
        item: Item,
        remaining_item_pool: list[Item],
        candidates: OrderedSet[Check] | None = None,
        use_logic: bool = True,
    ) -> None:
        """
        Places the given item in a location. Set `use_logic` to False to ignore logic
        and place the item in a completely random empty location.
        """
        reachable_null_checks: dict[Check, str] = {}

        if use_logic:
            # Figure out what nodes are accessible
            reachable_nodes = self.assumed_search(remaining_item_pool)

            for node in reachable_nodes:
                for check in node.checks:
                    if candidates is not None and check not in candidates:
                        continue
                    if check.contents is None:
                        reachable_null_checks[check] = node.name

        else:
            for area in self.aux_data.areas:
                for room in area.rooms:
                    for node in room.nodes:
                        for check in node.checks:
                            if candidates is not None and check not in candidates:
                                continue
                            if check.contents is None:
                                reachable_null_checks[check] = node.name

        locations = list(reachable_null_checks.keys())
        if len(locations) == 0:
            raise AssumedFillFailed()

        # Place the current item into a random location
        r = locations[random.randint(0, max(0, len(reachable_null_checks) - 1))]
        r.contents = item

        logger.info(f'Placed {item.name} at {reachable_null_checks[r]}')

    def _place_dungeon_rewards(self: Self, item_pool: list[Item]) -> None:
        logger.debug('Placing dungeon rewards...')
        dungeon_reward_pool = [
            item for item in item_pool if item.name in DUNGEON_REWARD_CHECKS.values()
        ]
        for item in dungeon_reward_pool:
            possible_checks: OrderedSet[Check] = OrderedSet(
                [
                    check
                    for area in self.aux_data.areas
                    for room in area.rooms
                    for node in room.nodes
                    for check in node.checks
                    if f'{node.area.name}.{node.room.name}.{check.name}' in DUNGEON_REWARD_CHECKS
                ]
            )
            self._place_item(item, item_pool, possible_checks, use_logic=False)
        for item in dungeon_reward_pool:
            item_pool.remove(item)

    def _place_boss_keys(self: Self, item_pool: list[Item]) -> None:
        """Place all boss keys in `item_pool`."""
        logger.debug('Placing boss keys...')
        key_pool = [item for item in item_pool if item.name.startswith('BossKey')]
        for item in key_pool:
            possible_checks: OrderedSet[Check] = OrderedSet(
                [
                    check
                    for area in self.aux_data.areas
                    for room in area.rooms
                    for node in room.nodes
                    for check in node.checks
                    if node.area.name == item.name[7:]
                ]
            )
            self._place_item(item, item_pool, possible_checks, use_logic=False)
        for item in key_pool:
            item_pool.remove(item)

    def _place_small_keys(self: Self, item_pool: list[Item]) -> None:
        """Place all small keys in `item_pool`."""
        logger.debug('Placing small keys...')
        key_pool = [item for item in item_pool if item.name.startswith('SmallKey_')]
        for item in key_pool:
            possible_checks: OrderedSet[Check] = OrderedSet(
                [
                    check
                    for area in self.aux_data.areas
                    for room in area.rooms
                    for node in room.nodes
                    for check in node.checks
                    if node.area.name == item.name[9:]
                ]
            )
            self._place_item(item, item_pool, possible_checks, use_logic=False)
        for item in key_pool:
            item_pool.remove(item)

    def _place_important_items(self: Self, item_pool: list[Item]) -> None:
        """Place all "important" items in the given item_pool."""
        logger.debug('Placing important items...')
        important_items = [item for item in item_pool if item.name in IMPORTANT_ITEMS]
        for item in important_items:
            self._place_item(item, item_pool)
        for item in important_items:
            item_pool.remove(item)

    def _place_rest_of_items(self: Self, item_pool: list[Item]) -> None:
        """Place all items remaining in item_pool."""
        logger.debug('Placing rest of items...')
        for item in item_pool:
            self._place_item(item, item_pool, use_logic=False)
        item_pool.clear()

    def exclude_check(self: Self, check: Check) -> None:
        logger.debug(f'Excluding check "{check.name}"')
        self._checks_to_exclude.add(check)

    def search(
        self: Self,
        items: list[Item],
        flags: set[str],
        states: set[str],
    ) -> OrderedSet[Node]:
        reachable_nodes: OrderedSet[Node] = OrderedSet()

        queue: deque[Node] = deque([self.starting_node])

        # Mapping to keep track of the state of `locks` (unlocked vs locked)
        locks: dict[str, bool] = {}

        visited_nodes: set[Node] = {self.starting_node}

        while len(queue) > 0:
            # Mapping to keep track of edges that contain an `open` descriptor, but are
            # otherwise traversable
            edges_with_locked_door: dict[str, list[Edge]] = defaultdict(list)
            while len(queue) > 0:
                r = queue.popleft()
                for edge in r.edges:
                    target = edge.dest

                    requirements_met = edge.is_traversable(
                        [i.name for i in items],
                        flags,
                        states,
                        self,
                    )

                    if requirements_met and target not in visited_nodes:
                        if edge.locked_door:
                            edges_with_locked_door[edge.src.area.name].append(edge)
                        else:
                            queue.append(target)
                            visited_nodes.add(target)
                reachable_nodes.add(r)

            # Calculate key counts for each area
            keys: dict[str, int] = defaultdict(int)
            for item in items:
                if item.name.startswith('SmallKey_'):
                    area_name = item.name[9:]
                    keys[area_name] += 1

            # Record any newly-reachable locked doors
            for node in reachable_nodes:
                if not node.lock:
                    continue
                full_lock_name = '.'.join([node.area.name, node.room.name, node.lock])
                if full_lock_name in locks:
                    continue
                locks[full_lock_name] = False

            # Figure out which doors we can unlock, and mark
            # them as "unlocked" + update key counts
            for area_name, edges in edges_with_locked_door.items():
                doors_to_unlock = {e.locked_door for e in edges}
                if keys[area_name] >= len(doors_to_unlock):
                    keys[area_name] -= len(doors_to_unlock)
                    for door in doors_to_unlock:
                        assert door is not None
                        locks[door] = True

            for edges in edges_with_locked_door.values():
                for edge in edges:
                    assert edge.locked_door is not None
                    if locks.get(edge.locked_door):
                        queue.append(edge.dest)
                        visited_nodes.add(edge.dest)

        return reachable_nodes

    def assumed_search(self: Self, items: list[Item], area: str | None = None) -> OrderedSet[Node]:
        # Used to keep track of what checks/flags we've encountered
        completed_checks: set[Check] = set()

        flags: set[str] = set()
        items = deepcopy(items)  # make copy of items so we don't mutate the original list
        states: set[str] = {state for item in items for state in item.states}

        while True:
            reachable_nodes = self.search(items, flags, states)
            if area is not None:
                reachable_nodes = OrderedSet(
                    [node for node in reachable_nodes if node.area.name == area]
                )
            found_new_items = False

            for node in reachable_nodes:
                for check in node.checks:
                    if check.contents and check not in completed_checks:
                        item = check.contents
                        # If this is a small key, append the area name to it
                        # so that we can tell what dungeon it goes to.
                        if item.name == 'SmallKey':
                            item.name += f'_{node.area.name}'
                        items.append(item)
                        states.update(item.states)
                        found_new_items = True
                        completed_checks.add(check)
                for flag in node.flags:
                    if flag not in flags:
                        flags.add(flag)
                        found_new_items = True
                for state in node.states_gained:
                    states.add(state)
                for state in node.states_lost:
                    if state in states:
                        states.remove(state)

            if not found_new_items:
                break

        return reachable_nodes