import unittest
import tkinter
from tkinter import messagebox, ttk
from test.support import requires, swap_attr
from test.test_tkinter.support import setUpModule  # noqa: F401
from test.test_tkinter.support import AbstractDefaultRootTest, AbstractTkTest
from tkinter.simpledialog import (Dialog, SimpleDialog,
                                  askinteger, askfloat, askstring,
                                  _QueryInteger, _QueryFloat, _QueryString,
                                  _underline_ampersand, _find_alt_key_target)

requires('gui')


class SimpleDialogTest(AbstractTkTest, unittest.TestCase):
    # SimpleDialog's modal loop is in go(); its bindings are exercised here by
    # generating events on the constructed dialog, without entering the loop.

    def create(self, **kw):
        kw.setdefault('text', 'Question?')
        kw.setdefault('buttons', ['Yes', 'No'])
        kw.setdefault('default', 0)
        kw.setdefault('cancel', 1)
        d = SimpleDialog(self.root, **kw)
        self.addCleanup(lambda: d.root.winfo_exists() and d.root.destroy())
        return d

    # --- Widget set and appearance ---

    def test_use_ttk(self):
        # By default SimpleDialog uses the themed (ttk) widgets (tk::MessageBox).
        d = self.create(buttons=['OK'], bitmap='warning')
        self.assertEqual(d._buttons[0].winfo_class(), 'TButton')
        self.assertEqual(d.message.winfo_class(), 'TLabel')
        self.assertEqual(d.message.cget('anchor'), 'nw')  # cf. MessageBox
        # The standard icons are drawn with themed images (cf. MessageBox).
        self.assertEqual(d.bitmap.winfo_class(), 'TLabel')
        self.assertIn('::tk::icons::warning', str(d.bitmap.cget('image')))
        # tk::MessageBox makes the buttons equal width.
        self.assertEqual(
            str(d.root.children['bot'].grid_columnconfigure(0)['uniform']),
            'buttons')
        # The dialog uses the themed background colour (cf. MessageBox).
        self.assertEqual(str(d.root.cget('background')),
                         ttk.Style(d.root).lookup('.', 'background'))
        # The bindings work with the themed buttons too.
        self.require_mapped(d.root)
        d._buttons[0].focus_force()
        d.root.update()
        d.root.event_generate('<Return>')
        d.root.update()
        self.assertEqual(d.num, 0)

    def test_use_classic(self):
        # use_ttk=False uses the classic Tk widgets, modelled on tk_dialog.
        d = self.create(buttons=['OK'], bitmap='warning', use_ttk=False)
        self.assertEqual(d._buttons[0].winfo_class(), 'Button')
        self.assertEqual(d.message.winfo_class(), 'Label')
        if d.root._windowingsystem == 'x11':
            self.assertEqual(d.frame.cget('relief'), 'raised')
        # tk_dialog does not make the buttons equal width.
        self.assertIsNone(d.root.children['bot'].grid_columnconfigure(0)['uniform'])
        # The bitmap is a classic monochrome label.
        self.assertEqual(str(d.bitmap.cget('bitmap')), 'warning')

    def test_class_name(self):
        # class_ sets the Tk class of the dialog window (default 'Dialog').
        self.assertEqual(self.create().root.winfo_class(), 'Dialog')
        d = self.create(class_='MyDialog')
        self.assertEqual(d.root.winfo_class(), 'MyDialog')

    # --- Message, detail and bitmap content ---

    def test_no_detail(self):
        # Without a detail message the message label expands.
        d = self.create()
        self.assertIsNone(d.detail)
        self.assertEqual(d.message.pack_info()['expand'],
                         1 if self.wantobjects else '1')

    def test_detail(self):
        # The detail message is shown below the main message.
        d = self.create(detail='More information.')
        self.assertEqual(d.detail.winfo_class(), 'TLabel')
        self.assertEqual(str(d.detail.cget('text')), 'More information.')
        self.assertEqual(d.detail.cget('anchor'), 'nw')  # cf. MessageBox
        # With a detail message it expands and the main message does not.
        self.assertEqual(d.message.pack_info()['expand'],
                         0 if self.wantobjects else '0')
        self.assertEqual(d.detail.pack_info()['expand'],
                         1 if self.wantobjects else '1')

    def test_bitmap_fallback(self):
        # A non-standard bitmap has no themed image, so even the ttk version
        # falls back to a classic bitmap label.
        d = self.create(buttons=['OK'], bitmap='questhead')
        self.assertEqual(d.bitmap.winfo_class(), 'Label')
        self.assertEqual(str(d.bitmap.cget('bitmap')), 'questhead')

    def test_bitmap_detail_layout(self):
        # The bitmap is packed first to claim the whole left side; the message
        # and detail stack on its right, as in tk::MessageBox.
        d = self.create(buttons=['OK'], bitmap='warning', detail='More.')
        top = d.root.children['top']
        layout = [(w.winfo_name(), str(w.pack_info()['side']))
                  for w in top.pack_slaves()]
        self.assertEqual(layout,
                         [('bitmap', 'left'), ('msg', 'top'), ('dtl', 'top')])

    # --- Buttons and keyboard ---

    def test_button_options(self):
        # A button entry can be a mapping of options, not just a label (like
        # the "[name ?-option value ...?]" button specs in tk::MessageBox).
        d = self.create(buttons=['Yes',
                                 {'text': 'No', 'underline': 0, 'width': 12}])
        yes, no = d._buttons
        self.assertEqual(str(yes.cget('text')), 'Yes')
        self.assertEqual(str(no.cget('text')), 'No')
        self.assertEqual(no.cget('underline'), 0 if self.wantobjects else '0')
        self.assertEqual(str(no.cget('width')), '12')
        # The dialog still controls the default ring (default=0) ...
        self.assertEqual(yes.cget('default'), 'active')
        self.assertEqual(no.cget('default'), 'normal')
        # ... and the command, which records the button index.
        no.invoke()
        self.assertEqual(d.num, 1)

    def test_default_ring(self):
        # The default ring follows the keyboard focus among the buttons
        # (cf. tk::MessageBox).
        d = self.create()  # buttons ['Yes', 'No'], default 0
        b0, b1 = d._buttons
        self.assertEqual(b1.cget('default'), 'normal')
        b1.focus_force()
        d.root.update()
        self.assertEqual(b1.cget('default'), 'active')   # focused -> ring
        b0.focus_force()
        d.root.update()
        self.assertEqual(b1.cget('default'), 'normal')   # unfocused -> none

    def test_alt_key(self):
        # Alt + an underlined character (the "underline" button option) invokes
        # the matching button (cf. tk::AmpWidget in tk::MessageBox).
        d = self.create(buttons=['Yes', {'text': 'No', 'underline': 0}])
        self.require_mapped(d.root)
        d._buttons[0].focus_force()
        d.root.update()
        d.root.event_generate('<Alt-n>')  # "No" -> underline 0 -> "N"
        d.root.update()
        self.assertEqual(d.num, 1)

    def test_return_invokes_focused_button(self):
        # <Return> invokes the button with the focus, even if it is not the
        # default and the focus was not moved by keyboard traversal.
        d = self.create(buttons=['Yes', 'No'])  # default 0
        self.require_mapped(d.root)
        d._buttons[1].focus_force()
        d.root.update()
        d.root.event_generate('<Return>')
        d.root.update()
        self.assertEqual(d.num, 1)

    def test_focus_next_then_return(self):
        # <Tab> moves the focus to the next button; <Return> invokes it.
        d = self.create(buttons=['Yes', 'No'])
        self.require_mapped(d.root)
        d._buttons[0].focus_force()
        d.root.update()
        d._buttons[0].event_generate('<Tab>')
        d.root.update()
        d.root.event_generate('<Return>')
        d.root.update()
        self.assertEqual(d.num, 1)

    def test_focus_prev_then_return(self):
        # <Shift-Tab> moves the focus to the previous button.
        d = self.create(buttons=['Yes', 'No'])
        self.require_mapped(d.root)
        d._buttons[1].focus_force()
        d.root.update()
        d._buttons[1].event_generate('<Shift-Tab>')
        d.root.update()
        d.root.event_generate('<Return>')
        d.root.update()
        self.assertEqual(d.num, 0)

    def test_return_activates_default(self):
        # <Return> with the focus off the buttons invokes the default button.
        d = self.create()  # default 0
        self.require_mapped(d.root)
        d.root.focus_force()  # the dialog, not a button, has the focus
        d.root.update()
        d.root.event_generate('<Return>')
        d.root.update()
        self.assertEqual(d.num, 0)

    def test_return_no_default(self):
        # With no default button, <Return> off the buttons rings the bell and
        # leaves the dialog open instead of activating a button.
        d = self.create(default=None)
        self.require_mapped(d.root)
        d.root.focus_force()  # the dialog, not a button, has the focus
        d.root.update()
        bells = []
        with swap_attr(d.root, 'bell', lambda *a, **k: bells.append(True)):
            d.root.event_generate('<Return>')
            d.root.update()
        self.assertTrue(bells)  # rang the bell
        self.assertIsNone(d.num)
        self.assertTrue(d.root.winfo_exists())

    # --- Modal lifecycle ---

    def test_destroy_cancels(self):
        # Destroying the window records the cancel index.
        d = self.create()
        d.root.update()
        d.root.destroy()
        self.assertEqual(d.num, 1)

    def test_wm_delete_cancels(self):
        # Closing the window through the window manager records the cancel index.
        d = self.create()  # cancel 1
        d.wm_delete_window()
        self.assertEqual(d.num, 1)

    def test_wm_delete_no_cancel(self):
        # With no cancel index, closing the window through the window manager
        # rings the bell and leaves the dialog open instead of recording an
        # index.
        d = self.create(default=None, cancel=None)
        d.root.update()
        bells = []
        with swap_attr(d.root, 'bell', lambda *a, **k: bells.append(True)):
            d.wm_delete_window()
            d.root.update()
        self.assertTrue(bells)  # rang the bell
        self.assertIsNone(d.num)
        self.assertTrue(d.root.winfo_exists())

    def test_go(self):
        # go() runs the modal loop and returns the chosen button's index.
        d = self.create()
        d.root.after(1, lambda: d._buttons[0].invoke())
        self.assertEqual(d.go(), 0)


class DialogTest(AbstractTkTest, unittest.TestCase):
    # Dialog's button box is modelled on tk::MessageBox.

    def open(self, **kw):
        with swap_attr(Dialog, 'wait_window', staticmethod(lambda w: None)):
            d = _QueryInteger('Title', 'Prompt', parent=self.root, **kw)
        self.addCleanup(lambda: d.winfo_exists() and d.destroy())
        return d

    # --- Widget set and appearance ---

    def test_use_ttk(self):
        # The query dialogs use the themed (ttk) widgets by default.
        d = self.open()
        self.assertEqual(d.children['ok'].winfo_class(), 'TButton')
        self.assertEqual(d.entry.winfo_class(), 'TEntry')
        # tk::MessageBox makes the buttons equal width.
        self.assertEqual(
            str(d.children['bot'].grid_columnconfigure(0)['uniform']), 'buttons')

    def test_use_classic(self):
        # use_ttk=False uses the classic Tk widgets, modelled on tk_dialog.
        d = self.open(use_ttk=False)
        self.assertEqual(d.children['ok'].winfo_class(), 'Button')
        self.assertEqual(d.entry.winfo_class(), 'Entry')
        if d._windowingsystem == 'x11':
            self.assertEqual(d.children['bot'].cget('relief'), 'raised')
        # tk_dialog does not make the buttons equal width.
        self.assertIsNone(d.children['bot'].grid_columnconfigure(0)['uniform'])
        # The bindings work with the classic buttons too.
        invoked = []
        cancel = d.children['cancel']
        cancel.configure(command=lambda: invoked.append(True))
        cancel.focus_force()
        d.update()
        d.event_generate('<Return>')
        d.update()
        self.assertTrue(invoked)

    def test_background(self):
        # The ttk dialog adopts the ttk background, even a customized one,
        # while the classic dialog keeps the default Toplevel background.
        style = ttk.Style(self.root)
        old = style.lookup('.', 'background')
        style.configure('.', background='#123456')
        self.addCleanup(style.configure, '.', background=old)
        d = self.open()
        self.assertEqual(str(d.cget('background')), '#123456')
        d = self.open(use_ttk=False)
        ref = tkinter.Toplevel(self.root)
        self.addCleanup(ref.destroy)
        self.assertEqual(str(d.cget('background')), str(ref.cget('background')))

    def test_base_classic_by_default(self):
        # The Dialog base defaults to classic widgets so that subclasses adding
        # classic widgets keep their look; only the query dialogs opt into ttk.
        class MyDialog(Dialog):
            def body(self, master):
                pass
        with swap_attr(Dialog, 'wait_window', staticmethod(lambda w: None)):
            d = MyDialog(self.root, 'Title')
        self.addCleanup(lambda: d.winfo_exists() and d.destroy())
        self.assertEqual(d.children['ok'].winfo_class(), 'Button')

    # --- Buttons and keyboard ---

    def test_button_default(self):
        d = self.open()
        self.assertEqual(d.children['ok'].cget('default'), 'active')
        self.assertEqual(d.children['cancel'].cget('default'), 'normal')

    def test_underline_ampersand(self):
        self.assertEqual(_underline_ampersand('Yes'), ('Yes', -1))
        self.assertEqual(_underline_ampersand('&Yes'), ('Yes', 0))
        self.assertEqual(_underline_ampersand('Save &As'), ('Save As', 5))
        self.assertEqual(_underline_ampersand('A&&B'), ('A&B', -1))
        self.assertEqual(_underline_ampersand('&a&b'), ('ab', 0))

    def test_button_accelerator(self):
        # The buttons' "&" accelerators are parsed (cf. tk::AmpWidget).
        d = self.open()
        ok = d.children['ok']  # "&OK" -> underline 0 -> "O"
        self.assertEqual(str(ok.cget('text')), 'OK')
        self.assertEqual(ok.cget('underline'), 0 if self.wantobjects else '0')

    def test_default_ring(self):
        # The default ring follows the keyboard focus among the buttons.
        d = self.open()
        cancel = d.children['cancel']
        self.assertEqual(cancel.cget('default'), 'normal')
        cancel.focus_force()
        d.update()
        self.assertEqual(cancel.cget('default'), 'active')
        d.children['ok'].focus_force()
        d.update()
        self.assertEqual(cancel.cget('default'), 'normal')

    def test_find_alt_key_target(self):
        d = self.open()
        ok = d.children['ok']          # "&OK" -> "O"
        cancel = d.children['cancel']  # "&Cancel" -> "C"
        self.assertIs(_find_alt_key_target(d, 'o'), ok)
        self.assertIs(_find_alt_key_target(d, 'O'), ok)  # case-insensitive
        self.assertIs(_find_alt_key_target(d, 'c'), cancel)
        self.assertIsNone(_find_alt_key_target(d, 'q'))

    def test_alt_key(self):
        # The accelerator key (Alt + the underlined letter) invokes the button:
        # <Alt-Key> -> _alt_key -> the button's <<AltUnderlined>> -> invoke.
        d = self.open()
        invoked = []
        cancel = d.children['cancel']  # "&Cancel"
        cancel.configure(command=lambda: invoked.append(True))
        d.focus_force()
        d.update()
        d.event_generate('<Alt-c>')
        d.update()
        self.assertTrue(invoked)

    def test_return_invokes_focused_button(self):
        # <Return> invokes the focused button.
        d = self.open()
        invoked = []
        cancel = d.children['cancel']
        cancel.configure(command=lambda: invoked.append(True))
        cancel.focus_force()
        d.update()
        d.event_generate('<Return>')
        d.update()
        self.assertEqual(invoked, [True])

    def test_focus_next_then_return(self):
        # <Tab> moves the focus to the next button; <Return> invokes it.
        d = self.open()
        invoked = []
        for name in ('ok', 'cancel'):
            d.children[name].configure(command=lambda name=name: invoked.append(name))
        ok = d.children['ok']
        ok.focus_force()
        d.update()
        ok.event_generate('<Tab>')  # OK -> Cancel
        d.update()
        d.event_generate('<Return>')
        d.update()
        self.assertEqual(invoked, ['cancel'])

    def test_focus_prev_then_return(self):
        # <Shift-Tab> moves the focus to the previous button.
        d = self.open()
        invoked = []
        for name in ('ok', 'cancel'):
            d.children[name].configure(command=lambda name=name: invoked.append(name))
        cancel = d.children['cancel']
        cancel.focus_force()
        d.update()
        cancel.event_generate('<Shift-Tab>')  # Cancel -> OK
        d.update()
        d.event_generate('<Return>')
        d.update()
        self.assertEqual(invoked, ['ok'])


class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase):

    def test_askinteger(self):
        @staticmethod
        def mock_wait_window(w):
            nonlocal ismapped
            ismapped = w.master.winfo_ismapped()
            w.destroy()

        with swap_attr(Dialog, 'wait_window', mock_wait_window):
            ismapped = None
            askinteger("Go To Line", "Line number")
            self.assertEqual(ismapped, False)

            root = tkinter.Tk()
            ismapped = None
            askinteger("Go To Line", "Line number")
            self.assertEqual(ismapped, True)
            root.destroy()

            tkinter.NoDefaultRoot()
            self.assertRaises(RuntimeError, askinteger, "Go To Line", "Line number")


class QueryDialogTest(AbstractTkTest, unittest.TestCase):
    # The query dialogs are modal: their __init__ blocks in wait_window().
    # Mock that out so the dialog stays alive and can be driven with generated
    # events, exercising the <Return>/<Escape> bindings and the validation.

    def open(self, query, **kw):
        with swap_attr(Dialog, 'wait_window', staticmethod(lambda w: None)):
            d = query("Title", "Prompt", parent=self.root, **kw)
        self.addCleanup(lambda: d.winfo_exists() and d.destroy())
        d.focus_force()
        d.update()
        return d

    def enter(self, d, value, key='<Return>'):
        d.entry.delete(0, 'end')
        d.entry.insert(0, value)
        d.event_generate(key)
        d.update()

    # --- Prompt and entry ---

    def test_prompt_wraplength(self):
        # A long prompt wraps instead of widening the dialog (cf. MessageBox).
        d = self.open(_QueryInteger)
        body = d.children['top']
        label = [w for w in body.winfo_children() if w is not d.entry][0]
        self.assertEqual(str(label.cget('wraplength')), '3i')

    def test_prompt_column_expands(self):
        # The prompt and entry column expands to the full width, like the
        # weighted message column in tk::MessageBox.
        d = self.open(_QueryInteger)
        body = d.children['top']
        self.assertEqual(body.grid_columnconfigure(0)['weight'], 1)

    def test_initialvalue(self):
        # The entry is pre-filled with the initial value, which is accepted.
        d = self.open(_QueryInteger, initialvalue=42)
        self.assertEqual(d.entry.get(), '42')
        d.event_generate('<Return>')
        d.update()
        self.assertEqual(d.result, 42)

    def test_show(self):
        # _QueryString hides the entered text when show is given.
        d = self.open(_QueryString, show='*')
        self.assertEqual(str(d.entry.cget('show')), '*')

    # --- Accept and cancel ---

    def test_return_accepts(self):
        for query, value, expected in [
            (_QueryInteger, '42', 42),
            (_QueryFloat, '1.5', 1.5),
            (_QueryString, 'spam', 'spam'),
        ]:
            with self.subTest(query=query.__name__):
                d = self.open(query)
                self.enter(d, value)
                self.assertEqual(d.result, expected)
                self.assertFalse(d.winfo_exists())  # The dialog closed.

    def test_escape_cancels(self):
        d = self.open(_QueryString)
        self.enter(d, 'spam', '<Escape>')
        self.assertIsNone(d.result)
        self.assertFalse(d.winfo_exists())

    # --- Validation ---

    def test_invalid_value(self):
        warnings = []
        d = self.open(_QueryInteger)
        with swap_attr(messagebox, 'showwarning',
                       lambda *a, **k: warnings.append(a)):
            self.enter(d, 'not a number')
        self.assertIsNone(d.result)
        self.assertTrue(d.winfo_exists())  # The dialog stays open.
        self.assertTrue(warnings)

    def test_out_of_range(self):
        warnings = []
        d = self.open(_QueryInteger, minvalue=10, maxvalue=20)
        with swap_attr(messagebox, 'showwarning',
                       lambda *a, **k: warnings.append(a)):
            self.enter(d, '5')   # Below the minimum.
            self.assertIsNone(d.result)
            self.enter(d, '25')  # Above the maximum.
            self.assertIsNone(d.result)
        self.assertTrue(d.winfo_exists())
        self.assertEqual(len(warnings), 2)

    def test_boundary_values_accepted(self):
        # The min/max checks are inclusive: a value equal to a bound passes.
        d = self.open(_QueryInteger, minvalue=10, maxvalue=20)
        self.enter(d, '10')   # Exactly the minimum.
        self.assertEqual(d.result, 10)
        self.assertFalse(d.winfo_exists())

        d = self.open(_QueryInteger, minvalue=10, maxvalue=20)
        self.enter(d, '20')   # Exactly the maximum.
        self.assertEqual(d.result, 20)
        self.assertFalse(d.winfo_exists())

    # --- Convenience ask* functions ---

    def run_ask(self, ask, value, **kw):
        # Drive a modal ask* function: enter a value and accept it.
        def accept(d):
            d.entry.delete(0, 'end')
            d.entry.insert(0, value)
            d.ok()
        with swap_attr(Dialog, 'wait_window', staticmethod(accept)):
            return ask('Title', 'Prompt', parent=self.root, **kw)

    def test_ask_functions(self):
        self.assertEqual(self.run_ask(askinteger, '42'), 42)
        self.assertEqual(self.run_ask(askfloat, '1.5'), 1.5)
        self.assertEqual(self.run_ask(askstring, 'spam'), 'spam')

    def test_ask_cancelled(self):
        # A cancelled ask* returns None.
        with swap_attr(Dialog, 'wait_window', staticmethod(lambda d: d.cancel())):
            self.assertIsNone(askstring('Title', 'Prompt', parent=self.root))


if __name__ == "__main__":
    unittest.main()
